Merge pull request '(feat): big call and chatting stuff' (#23) from dev into main
Reviewed-on: #23
This commit is contained in:
commit
aee2a0963d
153 changed files with 11308 additions and 5939 deletions
|
|
@ -18,6 +18,9 @@ jobs:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Pull git submodules
|
||||||
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|
@ -42,6 +45,9 @@ jobs:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Pull git submodules
|
||||||
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|
@ -128,6 +134,9 @@ jobs:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Pull git submodules
|
||||||
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|
@ -180,6 +189,9 @@ jobs:
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Pull git submodules
|
||||||
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,9 @@ jobs:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Pull git submodules
|
||||||
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|
@ -43,6 +46,9 @@ jobs:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Pull git submodules
|
||||||
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|
@ -129,6 +135,9 @@ jobs:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Pull git submodules
|
||||||
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|
@ -177,6 +186,9 @@ jobs:
|
||||||
- name: Check out repo
|
- name: Check out repo
|
||||||
uses: https://data.forgejo.org/actions/checkout@v4
|
uses: https://data.forgejo.org/actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Pull git submodules
|
||||||
|
run: git submodule update --init --recursive
|
||||||
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
|
|
||||||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@ node_modules
|
||||||
releases
|
releases
|
||||||
.fallow
|
.fallow
|
||||||
.direnv
|
.direnv
|
||||||
|
keystore.jks
|
||||||
|
keystore.properties
|
||||||
|
|
|
||||||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
[submodule "mtp-type-maps"]
|
||||||
|
path = mtp-type-maps
|
||||||
|
url = https://git.methanium.net/tensamin/mtp-type-maps
|
||||||
25
LICENSE
25
LICENSE
|
|
@ -1,16 +1,15 @@
|
||||||
Copyright (c) [2025] [Methanium]
|
Copyright (c) 2025 Methanium
|
||||||
|
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
This software is protected by copyright. Copying, editing,
|
No part of this software, source code, documentation, or
|
||||||
distributing, publicly performing, or any other use of this software
|
associated materials may be copied, reproduced, modified,
|
||||||
or its components, in source or binary form, is strictly prohibited without the express
|
distributed, published, sublicensed, sold, or used to create
|
||||||
written permission of the copyright holder.
|
derivative works without prior written permission from the
|
||||||
|
copyright holder.
|
||||||
|
|
||||||
FUTURE LICENSE ACCEPTANCE:
|
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
|
||||||
It is the copyright holder's intention to release this software in the future
|
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
|
||||||
under a license yet to be defined, which will, among other things,
|
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
|
||||||
allow private, non-commercial use. This statement does not constitute
|
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
||||||
a current license grant and does not alter the above
|
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
||||||
prohibition on use, copying, or modification. Until the formal
|
|
||||||
publication of such a future license, all rights remain
|
|
||||||
reserved.
|
|
||||||
|
|
|
||||||
7
TODO
Normal file
7
TODO
Normal file
|
|
@ -0,0 +1,7 @@
|
||||||
|
- Add a bunch of tests
|
||||||
|
- Add packages/hotkeys/
|
||||||
|
- Full accessability
|
||||||
|
- Add settings saving & update onboarding to use it
|
||||||
|
- Add onboarding page to load one profile during onboarding
|
||||||
|
|
||||||
|
-> Most folders in packages/ have specific todos
|
||||||
|
|
@ -95,7 +95,11 @@
|
||||||
"target": [
|
"target": [
|
||||||
"dmg"
|
"dmg"
|
||||||
],
|
],
|
||||||
"icon": "build/icons/icon.icns"
|
"icon": "build/icons/icon.icns",
|
||||||
|
"extendInfo": {
|
||||||
|
"NSCameraUsageDescription": "Tensamin uses your camera when you choose to share it in a call.",
|
||||||
|
"NSMicrophoneUsageDescription": "Tensamin uses your microphone for calls."
|
||||||
|
}
|
||||||
},
|
},
|
||||||
"publish": null
|
"publish": null
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -30,12 +30,22 @@ const verbose = process.argv.includes("--verbose");
|
||||||
let mainWindow: BrowserWindow | null = null;
|
let mainWindow: BrowserWindow | null = null;
|
||||||
let selectedScreenShareSourceId: string | null = null;
|
let selectedScreenShareSourceId: string | null = null;
|
||||||
|
|
||||||
|
app.setName("tensamin");
|
||||||
|
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
|
||||||
|
|
||||||
if (verbose) {
|
if (verbose) {
|
||||||
app.commandLine.appendSwitch("enable-logging", "stderr");
|
app.commandLine.appendSwitch("enable-logging", "stderr");
|
||||||
app.commandLine.appendSwitch("v", "1");
|
app.commandLine.appendSwitch("v", "1");
|
||||||
app.commandLine.appendSwitch("log-level", "0");
|
app.commandLine.appendSwitch("log-level", "0");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
process.platform === "linux" &&
|
||||||
|
!app.commandLine.hasSwitch("password-store")
|
||||||
|
) {
|
||||||
|
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
process.platform === "linux" &&
|
process.platform === "linux" &&
|
||||||
process.env.XDG_SESSION_TYPE === "wayland" &&
|
process.env.XDG_SESSION_TYPE === "wayland" &&
|
||||||
|
|
@ -170,6 +180,29 @@ function registerDisplayMediaHandler() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function registerMediaPermissionHandler() {
|
||||||
|
const isTrustedRenderer = (url: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url);
|
||||||
|
return parsed.protocol === "file:";
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
session.defaultSession.setPermissionCheckHandler(
|
||||||
|
(_webContents, permission, requestingOrigin) =>
|
||||||
|
permission === "media" && isTrustedRenderer(requestingOrigin),
|
||||||
|
);
|
||||||
|
session.defaultSession.setPermissionRequestHandler(
|
||||||
|
(_webContents, permission, callback, details) => {
|
||||||
|
callback(
|
||||||
|
permission === "media" && isTrustedRenderer(details.requestingUrl),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function registerIpc() {
|
function registerIpc() {
|
||||||
verboseLog("registering ipc handlers");
|
verboseLog("registering ipc handlers");
|
||||||
|
|
||||||
|
|
@ -350,6 +383,7 @@ async function start() {
|
||||||
verboseLog("app ready");
|
verboseLog("app ready");
|
||||||
registerIpc();
|
registerIpc();
|
||||||
registerDisplayMediaHandler();
|
registerDisplayMediaHandler();
|
||||||
|
registerMediaPermissionHandler();
|
||||||
initTray(() => mainWindow);
|
initTray(() => mainWindow);
|
||||||
await createWindow();
|
await createWindow();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,10 +34,6 @@ function validateValue(value: unknown): asserts value is string {
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getSecureStorageStatus(): DesktopSecureStorageStatus {
|
export function getSecureStorageStatus(): DesktopSecureStorageStatus {
|
||||||
if (!safeStorage.isEncryptionAvailable()) {
|
|
||||||
return { available: false, backend: null };
|
|
||||||
}
|
|
||||||
|
|
||||||
const backend =
|
const backend =
|
||||||
process.platform === "linux"
|
process.platform === "linux"
|
||||||
? safeStorage.getSelectedStorageBackend()
|
? safeStorage.getSelectedStorageBackend()
|
||||||
|
|
@ -48,7 +44,9 @@ export function getSecureStorageStatus(): DesktopSecureStorageStatus {
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available: process.platform !== "linux" || backend !== "basic_text",
|
available:
|
||||||
|
safeStorage.isEncryptionAvailable() &&
|
||||||
|
(process.platform !== "linux" || backend !== "basic_text"),
|
||||||
backend,
|
backend,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,14 +8,12 @@ const __filename = fileURLToPath(import.meta.url);
|
||||||
const __dirname = path.dirname(__filename);
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
function getTrayIconPath(filename: string) {
|
function getTrayIconPath(filename: string) {
|
||||||
if (app.isPackaged) return path.join(process.resourcesPath, "icons", filename);
|
if (app.isPackaged)
|
||||||
|
return path.join(process.resourcesPath, "icons", filename);
|
||||||
return path.resolve(__dirname, "../../build/icons", filename);
|
return path.resolve(__dirname, "../../build/icons", filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTrayCallStatus(
|
export function setTrayCallStatus(inCall: boolean, iconDataUrl?: string) {
|
||||||
inCall: boolean,
|
|
||||||
iconDataUrl?: string,
|
|
||||||
) {
|
|
||||||
if (!tray) return;
|
if (!tray) return;
|
||||||
|
|
||||||
if (inCall && iconDataUrl) {
|
if (inCall && iconDataUrl) {
|
||||||
|
|
|
||||||
3
apps/tauri/.gitignore
vendored
3
apps/tauri/.gitignore
vendored
|
|
@ -24,6 +24,3 @@ dist-ssr
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.android
|
.android
|
||||||
|
|
||||||
/src-tauri/gen/android/keystore.properties
|
|
||||||
/src-tauri/gen/android/keystore.jks
|
|
||||||
|
|
|
||||||
|
|
@ -4,10 +4,6 @@
|
||||||
"version": "0.0.0",
|
"version": "0.0.0",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"exports": {
|
"exports": {
|
||||||
"./controls": {
|
|
||||||
"types": "./src/windowControls.tsx",
|
|
||||||
"default": "./src/windowControls.tsx"
|
|
||||||
},
|
|
||||||
"./deeplinkHandler": {
|
"./deeplinkHandler": {
|
||||||
"types": "./src/deeplinkHandler.tsx",
|
"types": "./src/deeplinkHandler.tsx",
|
||||||
"default": "./src/deeplinkHandler.tsx"
|
"default": "./src/deeplinkHandler.tsx"
|
||||||
|
|
@ -35,7 +31,7 @@
|
||||||
"@tauri-apps/plugin-log": "~2",
|
"@tauri-apps/plugin-log": "~2",
|
||||||
"@tauri-apps/plugin-notification": "~2",
|
"@tauri-apps/plugin-notification": "~2",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/ui": "*",
|
"@methanium/ui": "*",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,12 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
<uses-permission android:name="android.permission.INTERNET" />
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.CAMERA" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||||
|
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
<!-- AndroidTV support -->
|
<!-- AndroidTV support -->
|
||||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||||
|
|
@ -39,6 +45,11 @@
|
||||||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
<service
|
||||||
|
android:name=".MediaProjectionService"
|
||||||
|
android:exported="false"
|
||||||
|
android:foregroundServiceType="mediaProjection" />
|
||||||
|
|
||||||
<provider
|
<provider
|
||||||
android:name="androidx.core.content.FileProvider"
|
android:name="androidx.core.content.FileProvider"
|
||||||
android:authorities="${applicationId}.fileprovider"
|
android:authorities="${applicationId}.fileprovider"
|
||||||
|
|
|
||||||
|
|
@ -1,20 +1,66 @@
|
||||||
package net.tensamin.client
|
package net.tensamin.client
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.pm.PackageManager
|
||||||
import android.graphics.Rect
|
import android.graphics.Rect
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
import android.os.Bundle
|
import android.os.Bundle
|
||||||
import android.view.ViewGroup
|
import android.view.ViewGroup
|
||||||
import android.view.ViewTreeObserver
|
import android.view.ViewTreeObserver
|
||||||
import android.view.WindowManager
|
import android.view.WindowManager
|
||||||
|
import android.webkit.JavascriptInterface
|
||||||
|
import android.webkit.WebView
|
||||||
import android.widget.FrameLayout
|
import android.widget.FrameLayout
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
import androidx.core.view.ViewCompat
|
import androidx.core.view.ViewCompat
|
||||||
import androidx.core.view.WindowCompat
|
import androidx.core.view.WindowCompat
|
||||||
import androidx.core.view.WindowInsetsCompat
|
import androidx.core.view.WindowInsetsCompat
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
class MainActivity : TauriActivity() {
|
class MainActivity : TauriActivity() {
|
||||||
private var contentRoot: FrameLayout? = null
|
private var contentRoot: FrameLayout? = null
|
||||||
private var contentChild: android.view.View? = null
|
private var contentChild: android.view.View? = null
|
||||||
private var previousUsableHeight = 0
|
private var previousUsableHeight = 0
|
||||||
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
|
||||||
|
private var mediaWebView: WebView? = null
|
||||||
|
private var pendingScreenAudio: Boolean? = null
|
||||||
|
|
||||||
|
private val screenCaptureLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.StartActivityForResult(),
|
||||||
|
) { result ->
|
||||||
|
val includeAudio = pendingScreenAudio
|
||||||
|
pendingScreenAudio = null
|
||||||
|
|
||||||
|
if (includeAudio == null) return@registerForActivityResult
|
||||||
|
val data = result.data
|
||||||
|
if (result.resultCode != Activity.RESULT_OK || data == null) {
|
||||||
|
MobileMediaEvents.emitError("Screen capture permission was denied")
|
||||||
|
return@registerForActivityResult
|
||||||
|
}
|
||||||
|
|
||||||
|
MediaProjectionService.start(this, result.resultCode, data, includeAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
private val cameraPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestMultiplePermissions(),
|
||||||
|
) {
|
||||||
|
emitCameraPermission()
|
||||||
|
}
|
||||||
|
|
||||||
|
private val screenAudioPermissionLauncher = registerForActivityResult(
|
||||||
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) {
|
||||||
|
launchScreenCaptureIntent()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onWebViewCreate(webView: WebView) {
|
||||||
|
webView.setInitialScale(300)
|
||||||
|
mediaWebView = webView
|
||||||
|
MobileMediaEvents.attach(webView)
|
||||||
|
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
|
||||||
|
}
|
||||||
|
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||||
|
|
@ -30,9 +76,84 @@ class MainActivity : TauriActivity() {
|
||||||
attachLayoutListener = null
|
attachLayoutListener = null
|
||||||
contentRoot = null
|
contentRoot = null
|
||||||
contentChild = null
|
contentChild = null
|
||||||
|
mediaWebView?.removeJavascriptInterface("tensaminMobileMedia")
|
||||||
|
mediaWebView = null
|
||||||
|
MobileMediaEvents.detach()
|
||||||
super.onDestroy()
|
super.onDestroy()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun startScreenShare(includeAudio: Boolean) {
|
||||||
|
runOnUiThread {
|
||||||
|
if (pendingScreenAudio != null) {
|
||||||
|
MobileMediaEvents.emitError("Screen capture permission is already pending")
|
||||||
|
return@runOnUiThread
|
||||||
|
}
|
||||||
|
|
||||||
|
pendingScreenAudio = includeAudio
|
||||||
|
if (
|
||||||
|
includeAudio &&
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
screenAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
|
||||||
|
} else {
|
||||||
|
launchScreenCaptureIntent()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun launchScreenCaptureIntent() {
|
||||||
|
val manager = getSystemService(MediaProjectionManager::class.java)
|
||||||
|
screenCaptureLauncher.launch(manager.createScreenCaptureIntent())
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopScreenShare() {
|
||||||
|
runOnUiThread {
|
||||||
|
pendingScreenAudio = null
|
||||||
|
MediaProjectionService.stop(this)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun requestCameraPermission() {
|
||||||
|
runOnUiThread {
|
||||||
|
cameraPermissionLauncher.launch(
|
||||||
|
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun emitCameraPermission() {
|
||||||
|
val detail = JSONObject()
|
||||||
|
.put(
|
||||||
|
"camera",
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED,
|
||||||
|
)
|
||||||
|
.put(
|
||||||
|
"microphone",
|
||||||
|
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
|
||||||
|
PackageManager.PERMISSION_GRANTED,
|
||||||
|
)
|
||||||
|
MobileMediaEvents.emit("tensamin-mobile-camera-permission", detail)
|
||||||
|
}
|
||||||
|
|
||||||
|
private inner class MobileMediaJavascriptInterface {
|
||||||
|
@JavascriptInterface
|
||||||
|
fun startScreenShare(includeAudio: Boolean) {
|
||||||
|
this@MainActivity.startScreenShare(includeAudio)
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun stopScreenShare() {
|
||||||
|
this@MainActivity.stopScreenShare()
|
||||||
|
}
|
||||||
|
|
||||||
|
@JavascriptInterface
|
||||||
|
fun requestCameraPermission() {
|
||||||
|
this@MainActivity.requestCameraPermission()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private fun installKeyboardResizeWorkaround() {
|
private fun installKeyboardResizeWorkaround() {
|
||||||
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
|
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
|
||||||
contentRoot = content
|
contentRoot = content
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,348 @@
|
||||||
|
package net.tensamin.client
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.NotificationChannel
|
||||||
|
import android.app.NotificationManager
|
||||||
|
import android.app.PendingIntent
|
||||||
|
import android.app.Service
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.content.pm.ServiceInfo
|
||||||
|
import android.graphics.Bitmap
|
||||||
|
import android.graphics.PixelFormat
|
||||||
|
import android.hardware.display.DisplayManager
|
||||||
|
import android.hardware.display.VirtualDisplay
|
||||||
|
import android.media.AudioAttributes
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioPlaybackCaptureConfiguration
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.projection.MediaProjection
|
||||||
|
import android.media.projection.MediaProjectionManager
|
||||||
|
import android.media.ImageReader
|
||||||
|
import android.os.Build
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.HandlerThread
|
||||||
|
import android.os.IBinder
|
||||||
|
import android.util.Base64
|
||||||
|
import android.util.DisplayMetrics
|
||||||
|
import androidx.core.content.ContextCompat
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
class MediaProjectionService : Service() {
|
||||||
|
private var projection: MediaProjection? = null
|
||||||
|
private var virtualDisplay: VirtualDisplay? = null
|
||||||
|
private var imageReader: ImageReader? = null
|
||||||
|
private var captureThread: HandlerThread? = null
|
||||||
|
private var audioRecord: AudioRecord? = null
|
||||||
|
private var audioThread: Thread? = null
|
||||||
|
private val captureActive = AtomicBoolean(false)
|
||||||
|
private var lastFrameAt = 0L
|
||||||
|
|
||||||
|
private val projectionCallback = object : MediaProjection.Callback() {
|
||||||
|
override fun onStop() {
|
||||||
|
stopCapture(stopProjection = false, emitStopped = true)
|
||||||
|
stopSelf()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onBind(intent: Intent?): IBinder? = null
|
||||||
|
|
||||||
|
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||||
|
if (intent?.action == ACTION_STOP) {
|
||||||
|
stopCapture(stopProjection = true, emitStopped = true)
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val permissionData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
intent?.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java)
|
||||||
|
} else {
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
intent?.getParcelableExtra(EXTRA_PERMISSION_DATA)
|
||||||
|
}
|
||||||
|
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Int.MIN_VALUE) ?: Int.MIN_VALUE
|
||||||
|
if (permissionData == null || resultCode == Int.MIN_VALUE) {
|
||||||
|
MobileMediaEvents.emitError("Screen capture permission data is missing")
|
||||||
|
stopSelf()
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
val includeAudio = intent?.getBooleanExtra(EXTRA_INCLUDE_AUDIO, false) ?: false
|
||||||
|
try {
|
||||||
|
startForegroundNotification()
|
||||||
|
startCapture(resultCode, permissionData, includeAudio)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
stopCapture(stopProjection = true, emitStopped = false)
|
||||||
|
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||||
|
stopSelf()
|
||||||
|
MobileMediaEvents.emitError(error.message ?: "Unable to start screen capture")
|
||||||
|
}
|
||||||
|
return START_NOT_STICKY
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
stopCapture(stopProjection = true, emitStopped = true)
|
||||||
|
super.onDestroy()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startForegroundNotification() {
|
||||||
|
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
notificationManager.createNotificationChannel(
|
||||||
|
NotificationChannel(
|
||||||
|
NOTIFICATION_CHANNEL_ID,
|
||||||
|
"Screen sharing",
|
||||||
|
NotificationManager.IMPORTANCE_LOW,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val stopIntent = Intent(this, MediaProjectionService::class.java).setAction(ACTION_STOP)
|
||||||
|
val stopPendingIntent = PendingIntent.getService(
|
||||||
|
this,
|
||||||
|
0,
|
||||||
|
stopIntent,
|
||||||
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||||
|
)
|
||||||
|
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||||
|
android.app.Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||||
|
} else {
|
||||||
|
android.app.Notification.Builder(this)
|
||||||
|
}
|
||||||
|
.setSmallIcon(android.R.drawable.ic_menu_share)
|
||||||
|
.setContentTitle("Tensamin is sharing your screen")
|
||||||
|
.setContentText("Tap Stop to end screen sharing")
|
||||||
|
.setOngoing(true)
|
||||||
|
.setCategory(android.app.Notification.CATEGORY_SERVICE)
|
||||||
|
.addAction(android.R.drawable.ic_media_pause, "Stop", stopPendingIntent)
|
||||||
|
.build()
|
||||||
|
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||||
|
startForeground(
|
||||||
|
NOTIFICATION_ID,
|
||||||
|
notification,
|
||||||
|
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
startForeground(NOTIFICATION_ID, notification)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Suppress("DEPRECATION")
|
||||||
|
private fun startCapture(resultCode: Int, permissionData: Intent, includeAudio: Boolean) {
|
||||||
|
stopCapture(stopProjection = true, emitStopped = false)
|
||||||
|
|
||||||
|
val manager = getSystemService(MediaProjectionManager::class.java)
|
||||||
|
val newProjection = manager.getMediaProjection(resultCode, permissionData)
|
||||||
|
?: error("Android did not provide a screen capture session")
|
||||||
|
projection = newProjection
|
||||||
|
|
||||||
|
val thread = HandlerThread("tensamin-screen-capture").also { it.start() }
|
||||||
|
captureThread = thread
|
||||||
|
val handler = Handler(thread.looper)
|
||||||
|
newProjection.registerCallback(projectionCallback, handler)
|
||||||
|
|
||||||
|
val metrics = DisplayMetrics()
|
||||||
|
getSystemService(android.view.WindowManager::class.java).defaultDisplay.getRealMetrics(metrics)
|
||||||
|
val displayWidth = metrics.widthPixels
|
||||||
|
val displayHeight = metrics.heightPixels
|
||||||
|
val width = minOf(displayWidth, MAX_FRAME_WIDTH)
|
||||||
|
val height = (displayHeight.toLong() * width / displayWidth).toInt()
|
||||||
|
val density = metrics.densityDpi
|
||||||
|
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
|
||||||
|
imageReader = reader
|
||||||
|
reader.setOnImageAvailableListener({ source -> captureFrame(source, width, height) }, handler)
|
||||||
|
|
||||||
|
virtualDisplay = newProjection.createVirtualDisplay(
|
||||||
|
"Tensamin screen sharing",
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
density,
|
||||||
|
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||||
|
reader.surface,
|
||||||
|
null,
|
||||||
|
handler,
|
||||||
|
)
|
||||||
|
captureActive.set(true)
|
||||||
|
|
||||||
|
val audioStarted = includeAudio && startAudioCapture(newProjection)
|
||||||
|
MobileMediaEvents.emit(
|
||||||
|
"tensamin-mobile-screen-started",
|
||||||
|
JSONObject().put("includeAudio", audioStarted),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureFrame(source: ImageReader, width: Int, height: Int) {
|
||||||
|
val image = source.acquireLatestImage() ?: return
|
||||||
|
try {
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (!captureActive.get() || now - lastFrameAt < FRAME_INTERVAL_MS) return
|
||||||
|
lastFrameAt = now
|
||||||
|
|
||||||
|
val plane = image.planes[0]
|
||||||
|
val paddedWidth = plane.rowStride / plane.pixelStride
|
||||||
|
val paddedBitmap = Bitmap.createBitmap(paddedWidth, height, Bitmap.Config.ARGB_8888)
|
||||||
|
paddedBitmap.copyPixelsFromBuffer(plane.buffer)
|
||||||
|
val croppedBitmap = if (paddedWidth == width) {
|
||||||
|
paddedBitmap
|
||||||
|
} else {
|
||||||
|
Bitmap.createBitmap(paddedBitmap, 0, 0, width, height).also { paddedBitmap.recycle() }
|
||||||
|
}
|
||||||
|
val outputBitmap = croppedBitmap
|
||||||
|
|
||||||
|
val bytes = ByteArrayOutputStream()
|
||||||
|
outputBitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, bytes)
|
||||||
|
val outputWidth = outputBitmap.width
|
||||||
|
val outputHeight = outputBitmap.height
|
||||||
|
outputBitmap.recycle()
|
||||||
|
MobileMediaEvents.emit(
|
||||||
|
"tensamin-mobile-screen-frame",
|
||||||
|
JSONObject()
|
||||||
|
.put("data", Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP))
|
||||||
|
.put("mimeType", "image/jpeg")
|
||||||
|
.put("width", outputWidth)
|
||||||
|
.put("height", outputHeight),
|
||||||
|
)
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
if (captureActive.get()) {
|
||||||
|
MobileMediaEvents.emitError(error.message ?: "Unable to read a screen frame")
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
image.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startAudioCapture(activeProjection: MediaProjection): Boolean {
|
||||||
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||||
|
MobileMediaEvents.emitError("System audio capture requires Android 10 or newer")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
|
||||||
|
PackageManager.PERMISSION_GRANTED
|
||||||
|
) {
|
||||||
|
MobileMediaEvents.emitError("Microphone permission is required for system audio capture")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return try {
|
||||||
|
val format = AudioFormat.Builder()
|
||||||
|
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||||
|
.setSampleRate(AUDIO_SAMPLE_RATE)
|
||||||
|
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||||
|
.build()
|
||||||
|
val configuration = AudioPlaybackCaptureConfiguration.Builder(activeProjection)
|
||||||
|
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||||
|
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||||
|
.build()
|
||||||
|
val minimumBuffer = AudioRecord.getMinBufferSize(
|
||||||
|
AUDIO_SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
)
|
||||||
|
check(minimumBuffer > 0) { "Android could not allocate a system audio buffer" }
|
||||||
|
val bufferSize = maxOf(minimumBuffer * 2, AUDIO_BATCH_BYTES)
|
||||||
|
val record = AudioRecord.Builder()
|
||||||
|
.setAudioFormat(format)
|
||||||
|
.setAudioPlaybackCaptureConfig(configuration)
|
||||||
|
.setBufferSizeInBytes(bufferSize)
|
||||||
|
.build()
|
||||||
|
check(record.state == AudioRecord.STATE_INITIALIZED) {
|
||||||
|
"Android could not initialize system audio capture"
|
||||||
|
}
|
||||||
|
audioRecord = record
|
||||||
|
record.startRecording()
|
||||||
|
audioThread = Thread({ readAudio(record) }, "tensamin-audio-capture").also { it.start() }
|
||||||
|
true
|
||||||
|
} catch (error: Throwable) {
|
||||||
|
audioRecord?.release()
|
||||||
|
audioRecord = null
|
||||||
|
MobileMediaEvents.emitError(error.message ?: "Unable to capture system audio")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readAudio(record: AudioRecord) {
|
||||||
|
val buffer = ByteArray(AUDIO_BATCH_BYTES)
|
||||||
|
while (captureActive.get() && !Thread.currentThread().isInterrupted) {
|
||||||
|
val read = record.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING)
|
||||||
|
if (read > 0 && captureActive.get()) {
|
||||||
|
MobileMediaEvents.emit(
|
||||||
|
"tensamin-mobile-screen-audio",
|
||||||
|
JSONObject()
|
||||||
|
.put("data", Base64.encodeToString(buffer, 0, read, Base64.NO_WRAP))
|
||||||
|
.put("sampleRate", AUDIO_SAMPLE_RATE)
|
||||||
|
.put("channelCount", 1)
|
||||||
|
.put("encoding", "pcm16le"),
|
||||||
|
)
|
||||||
|
} else if (read < 0 && captureActive.get()) {
|
||||||
|
MobileMediaEvents.emitError("System audio capture stopped with code $read")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
private fun stopCapture(stopProjection: Boolean, emitStopped: Boolean) {
|
||||||
|
val wasActive = captureActive.getAndSet(false)
|
||||||
|
|
||||||
|
val record = audioRecord
|
||||||
|
audioRecord = null
|
||||||
|
try {
|
||||||
|
record?.stop()
|
||||||
|
} catch (_: IllegalStateException) {
|
||||||
|
}
|
||||||
|
record?.release()
|
||||||
|
audioThread?.interrupt()
|
||||||
|
audioThread = null
|
||||||
|
|
||||||
|
imageReader?.setOnImageAvailableListener(null, null)
|
||||||
|
virtualDisplay?.release()
|
||||||
|
virtualDisplay = null
|
||||||
|
imageReader?.close()
|
||||||
|
imageReader = null
|
||||||
|
|
||||||
|
val oldProjection = projection
|
||||||
|
projection = null
|
||||||
|
oldProjection?.unregisterCallback(projectionCallback)
|
||||||
|
if (stopProjection) oldProjection?.stop()
|
||||||
|
|
||||||
|
captureThread?.quitSafely()
|
||||||
|
captureThread = null
|
||||||
|
lastFrameAt = 0L
|
||||||
|
|
||||||
|
if (wasActive && emitStopped) {
|
||||||
|
MobileMediaEvents.emit("tensamin-mobile-screen-stopped")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val ACTION_STOP = "net.tensamin.client.STOP_SCREEN_SHARE"
|
||||||
|
private const val EXTRA_RESULT_CODE = "resultCode"
|
||||||
|
private const val EXTRA_PERMISSION_DATA = "permissionData"
|
||||||
|
private const val EXTRA_INCLUDE_AUDIO = "includeAudio"
|
||||||
|
private const val NOTIFICATION_CHANNEL_ID = "screen-sharing"
|
||||||
|
private const val NOTIFICATION_ID = 7314
|
||||||
|
private const val FRAME_INTERVAL_MS = 75L
|
||||||
|
private const val MAX_FRAME_WIDTH = 1280
|
||||||
|
private const val JPEG_QUALITY = 72
|
||||||
|
private const val AUDIO_SAMPLE_RATE = 48_000
|
||||||
|
private const val AUDIO_BATCH_BYTES = 9_600
|
||||||
|
|
||||||
|
fun start(context: Context, resultCode: Int, data: Intent, includeAudio: Boolean) {
|
||||||
|
val intent = Intent(context, MediaProjectionService::class.java)
|
||||||
|
.putExtra(EXTRA_RESULT_CODE, resultCode)
|
||||||
|
.putExtra(EXTRA_PERMISSION_DATA, data)
|
||||||
|
.putExtra(EXTRA_INCLUDE_AUDIO, includeAudio)
|
||||||
|
ContextCompat.startForegroundService(context, intent)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun stop(context: Context) {
|
||||||
|
context.startService(
|
||||||
|
Intent(context, MediaProjectionService::class.java).setAction(ACTION_STOP),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package net.tensamin.client
|
||||||
|
|
||||||
|
import android.webkit.WebView
|
||||||
|
import java.lang.ref.WeakReference
|
||||||
|
import org.json.JSONObject
|
||||||
|
|
||||||
|
object MobileMediaEvents {
|
||||||
|
private var webView = WeakReference<WebView>(null)
|
||||||
|
|
||||||
|
fun attach(value: WebView) {
|
||||||
|
webView = WeakReference(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun detach() {
|
||||||
|
webView.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emitError(message: String) {
|
||||||
|
emit("tensamin-mobile-screen-error", JSONObject().put("message", message))
|
||||||
|
}
|
||||||
|
|
||||||
|
fun emit(name: String, detail: JSONObject = JSONObject()) {
|
||||||
|
val view = webView.get() ?: return
|
||||||
|
val script = "window.dispatchEvent(new CustomEvent(" +
|
||||||
|
JSONObject.quote(name) +
|
||||||
|
", { detail: " + detail.toString() + " }));"
|
||||||
|
view.post { view.evaluateJavascript(script, null) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
} from "react";
|
} from "react";
|
||||||
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { useIsMobile } from "@tensamin/ui";
|
import { useIsMobile } from "@methanium/ui";
|
||||||
|
|
||||||
type DeeplinkContextValue = {
|
type DeeplinkContextValue = {
|
||||||
deeplinks: readonly string[];
|
deeplinks: readonly string[];
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import {
|
||||||
Format,
|
Format,
|
||||||
requestPermissions,
|
requestPermissions,
|
||||||
} from "@tauri-apps/plugin-barcode-scanner";
|
} from "@tauri-apps/plugin-barcode-scanner";
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button } from "@methanium/ui";
|
||||||
|
|
||||||
import { toast } from "@tensamin/shared/log";
|
import { toast } from "@tensamin/shared/log";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,9 +60,10 @@
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/tauth": "workspace:*",
|
"@tensamin/tauth": "workspace:*",
|
||||||
"@tensamin/markdown": "workspace:*",
|
"@tensamin/markdown": "workspace:*",
|
||||||
"@tensamin/ui": "*",
|
"@methanium/ui": "*",
|
||||||
"@tensamin/user": "workspace:*",
|
"@tensamin/user": "workspace:*",
|
||||||
"@tensamin/notifications": "workspace:*",
|
"@tensamin/notifications": "workspace:*",
|
||||||
|
"@tensamin/onboarding": "workspace:*",
|
||||||
"aria-hidden": "^1.2.4",
|
"aria-hidden": "^1.2.4",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
BIN
apps/web/public/sounds/call_jingle_1.wav
Normal file
BIN
apps/web/public/sounds/call_jingle_1.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/call_jingle_2.wav
Normal file
BIN
apps/web/public/sounds/call_jingle_2.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/call_join.wav
Normal file
BIN
apps/web/public/sounds/call_join.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/call_leave.wav
Normal file
BIN
apps/web/public/sounds/call_leave.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/message.wav
Normal file
BIN
apps/web/public/sounds/message.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_end_other.wav
Normal file
BIN
apps/web/public/sounds/stream_end_other.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_end_self.wav
Normal file
BIN
apps/web/public/sounds/stream_end_self.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_start_other.wav
Normal file
BIN
apps/web/public/sounds/stream_start_other.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_start_self.wav
Normal file
BIN
apps/web/public/sounds/stream_start_self.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_watch_end.wav
Normal file
BIN
apps/web/public/sounds/stream_watch_end.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_watch_start.wav
Normal file
BIN
apps/web/public/sounds/stream_watch_start.wav
Normal file
Binary file not shown.
|
|
@ -6,9 +6,9 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
} from "@tensamin/ui";
|
Button,
|
||||||
import { Card, CardHeader } from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import { Skeleton } from "@tensamin/ui";
|
import { Skeleton } from "@methanium/ui";
|
||||||
import { getStatusColor } from "@tensamin/shared/data";
|
import { getStatusColor } from "@tensamin/shared/data";
|
||||||
|
|
||||||
export function Basic({
|
export function Basic({
|
||||||
|
|
@ -18,42 +18,50 @@ export function Basic({
|
||||||
user: User;
|
user: User;
|
||||||
extra?: React.ReactNode;
|
extra?: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
|
const display = user.Display || user.Username || "Unknown";
|
||||||
|
const onlineStatus = user.OnlineStatus || "user_borked";
|
||||||
|
const avatar = user.Avatar
|
||||||
|
? `data:image/webp;base64,${user.Avatar}`
|
||||||
|
: undefined;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Card className="animate-in fade-in duration-300 rounded-xl py-0 h-12.5!">
|
<Button
|
||||||
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
render={<div />}
|
||||||
<div className="relative shrink-0 overflow-visible">
|
nativeButton={false}
|
||||||
<Avatar>
|
variant="outline"
|
||||||
<AvatarImage src={user.Avatar} />
|
className="outline-none! animate-in fade-in duration-300 h-auto w-full justify-start gap-2.5 rounded-xl min-h-12.5!"
|
||||||
<AvatarFallback>
|
>
|
||||||
{user.Display.slice(0, 2).toUpperCase()}
|
<div className="relative shrink-0 overflow-visible">
|
||||||
</AvatarFallback>
|
<Avatar>
|
||||||
</Avatar>
|
<AvatarImage src={avatar} />
|
||||||
<Tooltip>
|
<AvatarFallback>{display.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||||
<TooltipTrigger
|
</Avatar>
|
||||||
render={
|
<Tooltip>
|
||||||
<div className="absolute -bottom-0.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card">
|
<TooltipTrigger
|
||||||
<div
|
render={
|
||||||
style={{
|
<div className="absolute -bottom-0.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card">
|
||||||
backgroundColor: getStatusColor(user.OnlineStatus),
|
<div
|
||||||
}}
|
style={{
|
||||||
className="h-2.25 w-2.25 rounded-full"
|
backgroundColor: getStatusColor(onlineStatus),
|
||||||
/>
|
}}
|
||||||
</div>
|
className="h-2.25 w-2.25 rounded-full"
|
||||||
}
|
/>
|
||||||
/>
|
</div>
|
||||||
<TooltipContent>
|
}
|
||||||
{user.OnlineStatus.split("_")
|
/>
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
<TooltipContent>
|
||||||
.join(" ")}
|
{onlineStatus
|
||||||
</TooltipContent>
|
.split("_")
|
||||||
</Tooltip>
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
</div>
|
.join(" ")}
|
||||||
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
</TooltipContent>
|
||||||
<p>{user.Display}</p>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
<div className="pr-1">{extra}</div>
|
<div className="flex w-full flex-col items-start justify-center gap-1 text-[15px]">
|
||||||
</CardHeader>
|
<p>{display}</p>
|
||||||
</Card>
|
</div>
|
||||||
|
<div className="pr-1">{extra}</div>
|
||||||
|
</Button>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
import { Avatar, AvatarFallback, AvatarImage, Button } from "@tensamin/ui";
|
import { Avatar, AvatarFallback, AvatarImage, Button } from "@methanium/ui";
|
||||||
import Text from "@tensamin/markdown/text";
|
import Text from "@tensamin/markdown/text";
|
||||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
@ -24,7 +24,7 @@ export default function Profile({ user }: { user: User }) {
|
||||||
<Text value={user.About || ""} />
|
<Text value={user.About || ""} />
|
||||||
<Button
|
<Button
|
||||||
className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline"
|
className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline"
|
||||||
variant="link"
|
variant="ghost"
|
||||||
aria-expanded={showAdvancedInformation}
|
aria-expanded={showAdvancedInformation}
|
||||||
onClick={() => setShowAdvancedInformation((show) => !show)}
|
onClick={() => setShowAdvancedInformation((show) => !show)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -4,17 +4,29 @@ import {
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
|
import {
|
||||||
|
ArrowLeft,
|
||||||
|
EllipsisVertical,
|
||||||
|
House,
|
||||||
|
Phone,
|
||||||
|
Settings,
|
||||||
|
User,
|
||||||
|
} from "lucide-react";
|
||||||
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
||||||
import { joinCall, useCall } from "@tensamin/call/store";
|
import { joinCall, useCall } from "@tensamin/call/store";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
import { Skeleton } from "@tensamin/ui";
|
import { Skeleton } from "@methanium/ui";
|
||||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@tensamin/ui";
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
} from "@methanium/ui";
|
||||||
import { displayCallId } from "@tensamin/call/utils";
|
import { displayCallId } from "@tensamin/call/utils";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { SidebarTrigger, useSidebar } from "@tensamin/ui";
|
import { SidebarTrigger, useSidebar } from "@methanium/ui";
|
||||||
import { WindowControls as Controls } from "@tensamin/ui";
|
import { WindowControls as Controls } from "@methanium/ui";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import Profile from "./modals/profile";
|
import Profile from "./modals/profile";
|
||||||
|
|
||||||
|
|
@ -35,37 +47,47 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
|
|
||||||
const [userInfoOpen, setUserInfoOpen] = useState(false);
|
const [userInfoOpen, setUserInfoOpen] = useState(false);
|
||||||
|
|
||||||
|
const { callId } = useCall();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
data-tauri-drag-region
|
data-tauri-drag-region
|
||||||
className={`${forMobile && "border-b"} w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
|
className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
|
||||||
>
|
>
|
||||||
<div className="flex items-center justify-center gap-2">
|
<div className="flex items-center justify-center gap-2">
|
||||||
{forMobile ? (
|
{forMobile ? (
|
||||||
<SidebarTrigger
|
<SidebarTrigger
|
||||||
className="w-9 h-9 aspect-square rounded-lg ml-2"
|
render={({ onClick }) => (
|
||||||
variant="outline"
|
<Button
|
||||||
>
|
onClick={onClick}
|
||||||
<ArrowLeft className="size-4.5" />
|
className="w-9 h-9! ml-2"
|
||||||
</SidebarTrigger>
|
variant="ghost"
|
||||||
|
>
|
||||||
|
<ArrowLeft className="size-4.5" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate({ to: "/" })}
|
onClick={() => navigate({ to: "/" })}
|
||||||
className="w-9 h-9 aspect-square rounded-lg"
|
className="w-9 h-9! aspect-square rounded-lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<House className="size-4.5" />
|
<House className="size-4.5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={() => navigate({ to: "/settings" })}
|
onClick={() => navigate({ to: "/settings" })}
|
||||||
className="w-9 h-9 aspect-square rounded-lg"
|
className="w-9 h-9! aspect-square rounded-lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Settings className="size-4.5" />
|
<Settings className="size-4.5" />
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isMobile && pathname === "/call" && callId && (
|
||||||
|
<p className="font-medium text-[1.07rem]">{displayCallId(callId)}</p>
|
||||||
|
)}
|
||||||
{pathname === "/chat" && id && (
|
{pathname === "/chat" && id && (
|
||||||
<Wrapper
|
<Wrapper
|
||||||
userId={id}
|
userId={id}
|
||||||
|
|
@ -75,10 +97,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
) : (
|
) : (
|
||||||
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
|
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={({ onClick }) => (
|
||||||
<Button
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
variant="link"
|
variant="link"
|
||||||
className="px-0! text-foreground"
|
className="text-foreground px-0! border-0! bg-none! bg-transparent!"
|
||||||
style={{
|
style={{
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
}}
|
}}
|
||||||
|
|
@ -87,7 +110,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
{user?.Display}
|
{user?.Display}
|
||||||
</p>
|
</p>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
<PopoverContent side="bottom">
|
<PopoverContent side="bottom">
|
||||||
<Profile user={user} />
|
<Profile user={user} />
|
||||||
|
|
@ -109,7 +132,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
void joinCall(id);
|
void joinCall(id);
|
||||||
}}
|
}}
|
||||||
className="w-9 h-9 aspect-square rounded-lg"
|
className="w-9 h-9! aspect-square rounded-lg"
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
<Phone />
|
<Phone />
|
||||||
|
|
@ -124,7 +147,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
currentCalls[0].CallId,
|
currentCalls[0].CallId,
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
className="w-9 h-9 aspect-square rounded-lg"
|
className="w-9 h-9! aspect-square rounded-lg"
|
||||||
>
|
>
|
||||||
<Phone />
|
<Phone />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -161,6 +184,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{isMobile && pathname === "/call" && callId && (
|
||||||
|
<Button variant="ghost">
|
||||||
|
<EllipsisVertical />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
<Controls className="mr-2" />
|
<Controls className="mr-2" />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -175,7 +203,7 @@ export function MobileNavbar() {
|
||||||
<div className="z-51 w-full shrink-0 pb-[env(safe-area-inset-bottom)] bg-card border-t">
|
<div className="z-51 w-full shrink-0 pb-[env(safe-area-inset-bottom)] bg-card border-t">
|
||||||
<div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5">
|
<div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5">
|
||||||
<SidebarTrigger
|
<SidebarTrigger
|
||||||
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1"
|
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1 border-0!"
|
||||||
variant="link"
|
variant="link"
|
||||||
>
|
>
|
||||||
<User className="size-4.5" />
|
<User className="size-4.5" />
|
||||||
|
|
@ -186,7 +214,7 @@ export function MobileNavbar() {
|
||||||
navigate({ to: "/" });
|
navigate({ to: "/" });
|
||||||
setOpenMobile(false);
|
setOpenMobile(false);
|
||||||
}}
|
}}
|
||||||
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
|
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!"
|
||||||
variant="link"
|
variant="link"
|
||||||
>
|
>
|
||||||
<House className="size-4.5" />
|
<House className="size-4.5" />
|
||||||
|
|
@ -197,7 +225,7 @@ export function MobileNavbar() {
|
||||||
navigate({ to: "/settings" });
|
navigate({ to: "/settings" });
|
||||||
setOpenMobile(false);
|
setOpenMobile(false);
|
||||||
}}
|
}}
|
||||||
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
|
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!"
|
||||||
variant="link"
|
variant="link"
|
||||||
>
|
>
|
||||||
<Settings className="size-4.5" />
|
<Settings className="size-4.5" />
|
||||||
|
|
|
||||||
|
|
@ -1,13 +1,21 @@
|
||||||
import { Button, cn, useIsMobile } from "@tensamin/ui";
|
import { Button, cn, useIsMobile } from "@methanium/ui";
|
||||||
import { Input } from "@tensamin/ui";
|
import { Input } from "@methanium/ui";
|
||||||
import { Label } from "@tensamin/ui";
|
import { Label } from "@methanium/ui";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { File } from "lucide-react";
|
import { File } from "lucide-react";
|
||||||
import * as React from "react";
|
import {
|
||||||
|
type ChangeEvent,
|
||||||
|
type FormEvent,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
||||||
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
|
|
||||||
const fetchedUser = z.object({
|
const fetchedUser = z.object({
|
||||||
id: z.uuidv4(),
|
id: z.uuidv4(),
|
||||||
|
|
@ -77,12 +85,13 @@ function parseTuFileContent(rawFileContent: string): {
|
||||||
|
|
||||||
export default function Form() {
|
export default function Form() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const uploadRef = React.useRef<HTMLInputElement | null>(null);
|
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||||
const [isDragging, setIsDragging] = React.useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
const { save } = useStorage();
|
const { save } = useStorage();
|
||||||
const loginPendingRef = React.useRef(false);
|
const navigate = useNavigate();
|
||||||
|
const loginPendingRef = useRef(false);
|
||||||
|
|
||||||
const persistLogin = React.useCallback(
|
const persistLogin = useCallback(
|
||||||
async (userId: number, privateKey: string, domain?: string | null) => {
|
async (userId: number, privateKey: string, domain?: string | null) => {
|
||||||
if (loginPendingRef.current) return false;
|
if (loginPendingRef.current) return false;
|
||||||
loginPendingRef.current = true;
|
loginPendingRef.current = true;
|
||||||
|
|
@ -91,22 +100,17 @@ export default function Form() {
|
||||||
await save("mtp_keyring", privateKey, { secure: true });
|
await save("mtp_keyring", privateKey, { secure: true });
|
||||||
await save("session_id", Date.now());
|
await save("session_id", Date.now());
|
||||||
await save("user_id", userId);
|
await save("user_id", userId);
|
||||||
window.history.replaceState(
|
await navigate({ to: "/", replace: true });
|
||||||
null,
|
|
||||||
"",
|
|
||||||
window.location.protocol === "file:" ? "#/" : "/",
|
|
||||||
);
|
|
||||||
window.location.reload();
|
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
loginPendingRef.current = false;
|
loginPendingRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[save],
|
[navigate, save],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process dropped files
|
// Process dropped files
|
||||||
const processDroppedFile = React.useCallback(
|
const processDroppedFile = useCallback(
|
||||||
async (file: globalThis.File): Promise<void> => {
|
async (file: globalThis.File): Promise<void> => {
|
||||||
try {
|
try {
|
||||||
if (!file.name.endsWith(".tu")) {
|
if (!file.name.endsWith(".tu")) {
|
||||||
|
|
@ -127,8 +131,8 @@ export default function Form() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle .tu files
|
// Handle .tu files
|
||||||
const handleFileInputChange = React.useCallback(
|
const handleFileInputChange = useCallback(
|
||||||
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||||
const file = event.currentTarget.files?.[0];
|
const file = event.currentTarget.files?.[0];
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
|
@ -142,7 +146,7 @@ export default function Form() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drag and drop listener
|
// Drag and drop listener
|
||||||
React.useEffect(() => {
|
useEffect(() => {
|
||||||
let dragCounter = 0;
|
let dragCounter = 0;
|
||||||
|
|
||||||
const handleDragEnter = (event: DragEvent) => {
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
|
|
@ -212,8 +216,8 @@ export default function Form() {
|
||||||
* @param event Form submit event.
|
* @param event Form submit event.
|
||||||
* @returns Promise that resolves after login processing.
|
* @returns Promise that resolves after login processing.
|
||||||
*/
|
*/
|
||||||
const handleCredentialsSubmit = React.useCallback(
|
const handleCredentialsSubmit = useCallback(
|
||||||
async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
|
|
||||||
const formData = new FormData(event.currentTarget);
|
const formData = new FormData(event.currentTarget);
|
||||||
|
|
|
||||||
|
|
@ -26,9 +26,9 @@ import {
|
||||||
SelectValue,
|
SelectValue,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { useIsMobile } from "@tensamin/ui";
|
import { useIsMobile } from "@methanium/ui";
|
||||||
import { MobileNavbar } from "./navbar";
|
import { MobileNavbar } from "./navbar";
|
||||||
|
|
||||||
import SidebarBox from "@tensamin/call/sidebarBox";
|
import SidebarBox from "@tensamin/call/sidebarBox";
|
||||||
|
|
@ -298,7 +298,7 @@ export default function Sidebar() {
|
||||||
data-sidebar="sidebar"
|
data-sidebar="sidebar"
|
||||||
data-slot="sidebar"
|
data-slot="sidebar"
|
||||||
data-mobile="true"
|
data-mobile="true"
|
||||||
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground transition-[transform,opacity] duration-150 ease-linear"
|
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground"
|
||||||
style={{
|
style={{
|
||||||
transform: openMobile ? "translateX(0)" : "translateX(-100%)",
|
transform: openMobile ? "translateX(0)" : "translateX(-100%)",
|
||||||
opacity: openMobile ? 1 : 0,
|
opacity: openMobile ? 1 : 0,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import * as React from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||||
|
|
||||||
import Switch from "./switch";
|
import Switch from "./switch";
|
||||||
|
|
@ -8,14 +8,14 @@ import { Loader2 } from "lucide-react";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
|
|
||||||
export default function List() {
|
export default function List() {
|
||||||
const [category, setCategory] = React.useState<
|
const [category, setCategory] = useState<"conversations" | "communities">(
|
||||||
"conversations" | "communities"
|
"conversations",
|
||||||
>("conversations");
|
);
|
||||||
|
|
||||||
const { contacts, communities } = useSession();
|
const { contacts, communities } = useSession();
|
||||||
const items = category === "conversations" ? contacts : communities;
|
const items = category === "conversations" ? contacts : communities;
|
||||||
|
|
||||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
|
||||||
// TanStack Virtual is intentionally used here; React Compiler memoization is skipped.
|
// TanStack Virtual is intentionally used here; React Compiler memoization is skipped.
|
||||||
// eslint-disable-next-line react-hooks/incompatible-library
|
// eslint-disable-next-line react-hooks/incompatible-library
|
||||||
|
|
@ -31,7 +31,7 @@ export default function List() {
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
id="conversation-list"
|
id="conversation-list"
|
||||||
className="overflow-y-auto flex-1 h-full"
|
className="overflow-y-auto flex-1 h-full p-px"
|
||||||
>
|
>
|
||||||
{items === null ? (
|
{items === null ? (
|
||||||
<div className="flex items-center justify-center pt-5">
|
<div className="flex items-center justify-center pt-5">
|
||||||
|
|
|
||||||
|
|
@ -6,9 +6,9 @@ import {
|
||||||
ContextMenuGroup,
|
ContextMenuGroup,
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useSidebar } from "@tensamin/ui";
|
import { useSidebar } from "@methanium/ui";
|
||||||
|
|
||||||
export default function ConversationModal({ userId }: { userId: number }) {
|
export default function ConversationModal({ userId }: { userId: number }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
|
||||||
|
|
@ -1,247 +0,0 @@
|
||||||
import { useState, useCallback, useEffect } from "react";
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
|
||||||
import { Button } from "@tensamin/ui";
|
|
||||||
import { Checkbox } from "@tensamin/ui";
|
|
||||||
import { z } from "zod";
|
|
||||||
|
|
||||||
import { ErrorScreen } from "@tensamin/ui";
|
|
||||||
|
|
||||||
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
|
||||||
import { log } from "@tensamin/shared/log";
|
|
||||||
import { Link } from "@tensamin/ui";
|
|
||||||
import { Label } from "@tensamin/ui";
|
|
||||||
import { CreateScreen } from "@tensamin/ui";
|
|
||||||
|
|
||||||
// Prevents the user from using Tensamin without accepting the privacy policy and terms of service.
|
|
||||||
export default function Screen(props: { children: React.ReactNode }) {
|
|
||||||
const { load, save } = useStorage();
|
|
||||||
|
|
||||||
const [error, setError] = useState("");
|
|
||||||
const [errorDescription, setErrorDescription] = useState("");
|
|
||||||
|
|
||||||
const [remoteDocs, setRemoteDocs] = useState<
|
|
||||||
z.infer<typeof legalDocsSchema> | undefined
|
|
||||||
>(undefined);
|
|
||||||
const [localDocs, setLocalDocs] = useState<
|
|
||||||
z.infer<typeof legalDocsSchema> | undefined
|
|
||||||
>(undefined);
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
|
|
||||||
const [acceptedPP, acceptPP] = useState(false);
|
|
||||||
const [acceptedTOS, acceptTOS] = useState(false);
|
|
||||||
const [hasContinued, setHasContinued] = useState(false);
|
|
||||||
|
|
||||||
const [userId, setUserId] = useState<number | undefined>(undefined);
|
|
||||||
|
|
||||||
// Saves
|
|
||||||
const handleContinueLegal = useCallback((): void => {
|
|
||||||
const currentDocs = remoteDocs;
|
|
||||||
if (!currentDocs) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
save("accepted_privacy_policy", true);
|
|
||||||
save("accepted_terms_of_service", true);
|
|
||||||
save("legal_docs", currentDocs);
|
|
||||||
setHasContinued(true);
|
|
||||||
}, [remoteDocs, save]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
void (async () => {
|
|
||||||
try {
|
|
||||||
const id = await load("user_id");
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setUserId(id);
|
|
||||||
|
|
||||||
if (id === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const current = await fetch("https://legal.tensamin.net/api/current")
|
|
||||||
.then((res) => res.json())
|
|
||||||
.catch((err) => {
|
|
||||||
if (!active) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
setError("Failed to load legal documents");
|
|
||||||
setErrorDescription(
|
|
||||||
"An error occurred while fetching the legal documents from the server. Please try again later.",
|
|
||||||
);
|
|
||||||
log(0, "Legal", "red", "Failed to fetch legal documents", err);
|
|
||||||
return undefined;
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!active || current === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const safeCurrent = legalDocsSchema.safeParse(current);
|
|
||||||
if (!safeCurrent.success) {
|
|
||||||
setError("Failed to load legal documents");
|
|
||||||
setErrorDescription(
|
|
||||||
"The legal documents data received from the server is invalid. Please try again later.",
|
|
||||||
);
|
|
||||||
log(
|
|
||||||
0,
|
|
||||||
"Legal",
|
|
||||||
"red",
|
|
||||||
"Invalid legal documents data",
|
|
||||||
safeCurrent.error,
|
|
||||||
);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setRemoteDocs(safeCurrent.data);
|
|
||||||
|
|
||||||
const currentLocalDocs = await load("legal_docs");
|
|
||||||
setLocalDocs(currentLocalDocs);
|
|
||||||
|
|
||||||
const [loadedAcceptedPP, loadedAcceptedTOS] = await Promise.all([
|
|
||||||
load("accepted_privacy_policy"),
|
|
||||||
load("accepted_terms_of_service"),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
acceptPP(
|
|
||||||
loadedAcceptedPP &&
|
|
||||||
!!currentLocalDocs &&
|
|
||||||
currentLocalDocs.pp.hash === safeCurrent.data.pp.hash,
|
|
||||||
);
|
|
||||||
acceptTOS(
|
|
||||||
loadedAcceptedTOS &&
|
|
||||||
!!currentLocalDocs &&
|
|
||||||
currentLocalDocs.tos.hash === safeCurrent.data.tos.hash,
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
if (active) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [load]);
|
|
||||||
|
|
||||||
if (error !== "" && errorDescription !== "") {
|
|
||||||
return <ErrorScreen error={error} description={errorDescription} />;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (loading || userId === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const docsMatch =
|
|
||||||
localDocs !== undefined &&
|
|
||||||
remoteDocs !== undefined &&
|
|
||||||
localDocs.pp.hash === remoteDocs.pp.hash &&
|
|
||||||
localDocs.tos.hash === remoteDocs.tos.hash;
|
|
||||||
|
|
||||||
if (
|
|
||||||
(acceptedPP && acceptedTOS && (docsMatch || hasContinued)) ||
|
|
||||||
userId === 0
|
|
||||||
) {
|
|
||||||
return <>{props.children}</>;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<CreateScreen>
|
|
||||||
<div className="h-full flex flex-col gap-15 p-10 py-20 md:p-40 w-full lg:w-2/3">
|
|
||||||
<h1 className="text-3xl md:text-4xl font-bold">
|
|
||||||
Privacy Policy & ToS
|
|
||||||
<p className="text-muted-foreground text-[20px] font-normal pt-3">
|
|
||||||
{remoteDocs?.pp.version} / {remoteDocs?.tos.version}
|
|
||||||
</p>
|
|
||||||
</h1>
|
|
||||||
<div className="w-full h-full flex flex-col items-center justify-center gap-5">
|
|
||||||
<div className="justify-start items-start flex flex-col gap-2">
|
|
||||||
<BigCheckbox
|
|
||||||
id="acceptPP"
|
|
||||||
checked={acceptedPP}
|
|
||||||
onChange={acceptPP}
|
|
||||||
label="I agree to the Privacy Policy"
|
|
||||||
/>
|
|
||||||
<BigCheckbox
|
|
||||||
id="acceptTOS"
|
|
||||||
checked={acceptedTOS}
|
|
||||||
onChange={acceptTOS}
|
|
||||||
label="I agree to the Terms of Service"
|
|
||||||
/>
|
|
||||||
<div className="w-full border-t-2" />
|
|
||||||
<Link
|
|
||||||
label="Privacy Policy"
|
|
||||||
link={`https://legal.tensamin.net/pp/${remoteDocs?.pp.version}`}
|
|
||||||
/>
|
|
||||||
<Link
|
|
||||||
label="Terms of Service"
|
|
||||||
link={`https://legal.tensamin.net/tos/${remoteDocs?.tos.version}`}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<ContinueButton
|
|
||||||
disabled={!acceptedPP || !acceptedTOS}
|
|
||||||
onClick={handleContinueLegal}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</CreateScreen>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Components
|
|
||||||
function ContinueButton({
|
|
||||||
onClick,
|
|
||||||
disabled,
|
|
||||||
}: {
|
|
||||||
onClick: () => void;
|
|
||||||
disabled?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="w-full flex justify-end">
|
|
||||||
<Button
|
|
||||||
size="lg"
|
|
||||||
className="text-md w-full md:w-auto"
|
|
||||||
onClick={onClick}
|
|
||||||
disabled={disabled}
|
|
||||||
>
|
|
||||||
Continue
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function BigCheckbox({
|
|
||||||
id,
|
|
||||||
label,
|
|
||||||
checked,
|
|
||||||
onChange,
|
|
||||||
}: {
|
|
||||||
id: string;
|
|
||||||
label: string;
|
|
||||||
checked: boolean;
|
|
||||||
onChange: (checked: boolean) => void;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Checkbox
|
|
||||||
id={id}
|
|
||||||
checked={checked}
|
|
||||||
onCheckedChange={onChange}
|
|
||||||
className="size-5.5 rounded-md flex items-center justify-center"
|
|
||||||
/>
|
|
||||||
<Label htmlFor={id} className="text-lg">
|
|
||||||
{label}
|
|
||||||
</Label>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -9,19 +9,19 @@ import {
|
||||||
} from "@tanstack/react-router";
|
} from "@tanstack/react-router";
|
||||||
|
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
import "@tensamin/ui/index.css";
|
import "@methanium/ui/index.css";
|
||||||
|
|
||||||
import NotFound from "@/routes/404";
|
import NotFound from "@/routes/404";
|
||||||
|
|
||||||
import AppLayout from "@/routes/app/layout";
|
import AppLayout from "@/routes/app/layout";
|
||||||
import { createSettingsRoute } from "@tensamin/settings";
|
import { createSettingsRoute } from "@tensamin/settings";
|
||||||
|
import OnboardingGate from "@tensamin/onboarding";
|
||||||
|
|
||||||
import Home from "@/routes/app/home";
|
import Home from "@/routes/app/home";
|
||||||
import ChatScreen from "@tensamin/chat/screen";
|
import ChatScreen from "@tensamin/chat/screen";
|
||||||
import CallScreen from "@tensamin/call/screen";
|
import CallScreen from "@tensamin/call/screen";
|
||||||
import Login from "@/routes/screens/login";
|
import Login from "@/routes/screens/login";
|
||||||
|
|
||||||
import CallPopout from "@tensamin/call/popout";
|
|
||||||
import ChatContext from "@tensamin/chat/context";
|
import ChatContext from "@tensamin/chat/context";
|
||||||
import { useCall, useInitializeCall } from "@tensamin/call/store";
|
import { useCall, useInitializeCall } from "@tensamin/call/store";
|
||||||
import { useIsSpeaking } from "@tensamin/call/speakingState";
|
import { useIsSpeaking } from "@tensamin/call/speakingState";
|
||||||
|
|
@ -32,7 +32,7 @@ import NotificationsProvider from "@tensamin/notifications/context";
|
||||||
|
|
||||||
import TAuthWrapper from "@tensamin/tauth/context";
|
import TAuthWrapper from "@tensamin/tauth/context";
|
||||||
|
|
||||||
import { ErrorScreen, ThemeProvider, useTheme } from "@tensamin/ui";
|
import { ErrorScreen, ThemeProvider, useTheme } from "@methanium/ui";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
|
||||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||||
|
|
@ -43,12 +43,11 @@ import Crypto from "@tensamin/crypto/context";
|
||||||
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
||||||
import { log } from "@tensamin/shared/log";
|
import { log } from "@tensamin/shared/log";
|
||||||
|
|
||||||
import LegalWrapper from "@/features/legal/screen";
|
|
||||||
import CacheSync from "@tensamin/cache/sync";
|
import CacheSync from "@tensamin/cache/sync";
|
||||||
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import { useIsMobile, Toaster, TooltipProvider } from "@tensamin/ui";
|
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
const wrapper = document.getElementById("root");
|
const wrapper = document.getElementById("root");
|
||||||
|
|
@ -131,6 +130,11 @@ function ThemeStorageBridge() {
|
||||||
setThemeBorderRadius,
|
setThemeBorderRadius,
|
||||||
themeCustomCss,
|
themeCustomCss,
|
||||||
setThemeCustomCss,
|
setThemeCustomCss,
|
||||||
|
parentThemeId,
|
||||||
|
setParentThemeId,
|
||||||
|
applyThemePreset,
|
||||||
|
themeDesign,
|
||||||
|
setThemeDesign,
|
||||||
} = useTheme();
|
} = useTheme();
|
||||||
const loadedRef = useRef(false);
|
const loadedRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -145,6 +149,8 @@ function ThemeStorageBridge() {
|
||||||
load("theme_tint"),
|
load("theme_tint"),
|
||||||
load("theme_border_radius"),
|
load("theme_border_radius"),
|
||||||
load("theme_custom_css"),
|
load("theme_custom_css"),
|
||||||
|
load("theme_parent"),
|
||||||
|
load("theme_design"),
|
||||||
]).then(
|
]).then(
|
||||||
([
|
([
|
||||||
color,
|
color,
|
||||||
|
|
@ -154,18 +160,23 @@ function ThemeStorageBridge() {
|
||||||
tint,
|
tint,
|
||||||
borderRadius,
|
borderRadius,
|
||||||
customCss,
|
customCss,
|
||||||
|
parent,
|
||||||
|
design,
|
||||||
]) => {
|
]) => {
|
||||||
if (!active) {
|
if (!active) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (customCss === "" && parent) applyThemePreset(parent);
|
||||||
|
else setParentThemeId(parent || null);
|
||||||
setThemeColor(color);
|
setThemeColor(color);
|
||||||
setThemePalette(palette);
|
setThemePalette(palette);
|
||||||
setThemePrimaryColor(primaryColor);
|
setThemePrimaryColor(primaryColor);
|
||||||
setThemePolarity(polarity);
|
setThemePolarity(polarity);
|
||||||
setThemeTint(tint);
|
setThemeTint(tint);
|
||||||
setThemeBorderRadius(borderRadius);
|
setThemeBorderRadius(borderRadius);
|
||||||
setThemeCustomCss(customCss);
|
if (customCss !== "") setThemeCustomCss(customCss);
|
||||||
|
setThemeDesign(design);
|
||||||
loadedRef.current = true;
|
loadedRef.current = true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -175,9 +186,12 @@ function ThemeStorageBridge() {
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
load,
|
load,
|
||||||
|
applyThemePreset,
|
||||||
setThemeBorderRadius,
|
setThemeBorderRadius,
|
||||||
setThemeColor,
|
setThemeColor,
|
||||||
setThemeCustomCss,
|
setThemeCustomCss,
|
||||||
|
setParentThemeId,
|
||||||
|
setThemeDesign,
|
||||||
setThemePalette,
|
setThemePalette,
|
||||||
setThemePolarity,
|
setThemePolarity,
|
||||||
setThemePrimaryColor,
|
setThemePrimaryColor,
|
||||||
|
|
@ -212,6 +226,14 @@ function ThemeStorageBridge() {
|
||||||
if (loadedRef.current) save("theme_custom_css", themeCustomCss);
|
if (loadedRef.current) save("theme_custom_css", themeCustomCss);
|
||||||
}, [save, themeCustomCss]);
|
}, [save, themeCustomCss]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadedRef.current) save("theme_parent", parentThemeId ?? "");
|
||||||
|
}, [parentThemeId, save]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (loadedRef.current) save("theme_design", themeDesign);
|
||||||
|
}, [save, themeDesign]);
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -228,6 +250,8 @@ function RootShell() {
|
||||||
tintStorageKey={null}
|
tintStorageKey={null}
|
||||||
borderRadiusStorageKey={null}
|
borderRadiusStorageKey={null}
|
||||||
customCssStorageKey={null}
|
customCssStorageKey={null}
|
||||||
|
parentThemeStorageKey={null}
|
||||||
|
designStorageKey={null}
|
||||||
>
|
>
|
||||||
<div className="w-screen h-dvh overflow-hidden">
|
<div className="w-screen h-dvh overflow-hidden">
|
||||||
<Toaster
|
<Toaster
|
||||||
|
|
@ -244,13 +268,7 @@ function RootShell() {
|
||||||
<Storage>
|
<Storage>
|
||||||
<ThemeStorageBridge />
|
<ThemeStorageBridge />
|
||||||
<LoginWrapper>
|
<LoginWrapper>
|
||||||
<LegalWrapper>
|
<Outlet />
|
||||||
<Crypto>
|
|
||||||
<DesktopMediaProvider>
|
|
||||||
<Outlet />
|
|
||||||
</DesktopMediaProvider>
|
|
||||||
</Crypto>
|
|
||||||
</LegalWrapper>
|
|
||||||
</LoginWrapper>
|
</LoginWrapper>
|
||||||
</Storage>
|
</Storage>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
@ -262,24 +280,29 @@ function RootShell() {
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
return (
|
return (
|
||||||
<MTPProvider>
|
<OnboardingGate>
|
||||||
<CacheSync />
|
<Crypto>
|
||||||
<Session>
|
<DesktopMediaProvider>
|
||||||
<UserProvider>
|
<MTPProvider>
|
||||||
<CallInit />
|
<CacheSync />
|
||||||
<CallPopout />
|
<Session>
|
||||||
<TAuthWrapper>
|
<UserProvider>
|
||||||
<AppLayout>
|
<CallInit />
|
||||||
<ChatContext>
|
<TAuthWrapper>
|
||||||
<NotificationsProvider>
|
<AppLayout>
|
||||||
<Outlet />
|
<ChatContext>
|
||||||
</NotificationsProvider>
|
<NotificationsProvider>
|
||||||
</ChatContext>
|
<Outlet />
|
||||||
</AppLayout>
|
</NotificationsProvider>
|
||||||
</TAuthWrapper>
|
</ChatContext>
|
||||||
</UserProvider>
|
</AppLayout>
|
||||||
</Session>
|
</TAuthWrapper>
|
||||||
</MTPProvider>
|
</UserProvider>
|
||||||
|
</Session>
|
||||||
|
</MTPProvider>
|
||||||
|
</DesktopMediaProvider>
|
||||||
|
</Crypto>
|
||||||
|
</OnboardingGate>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -307,7 +330,7 @@ function createCallTrayIcon(color: string, speaking: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function CallInit() {
|
function CallInit() {
|
||||||
useInitializeCall();
|
const callInvitePopup = useInitializeCall();
|
||||||
|
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const {
|
const {
|
||||||
|
|
@ -370,7 +393,7 @@ function CallInit() {
|
||||||
});
|
});
|
||||||
}, [inCall, primaryColor, speaking]);
|
}, [inCall, primaryColor, speaking]);
|
||||||
|
|
||||||
return null;
|
return callInvitePopup;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
Input,
|
Input,
|
||||||
Button,
|
Button,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
@ -79,7 +79,7 @@ function AddConversationButton() {
|
||||||
Username: result.data,
|
Username: result.data,
|
||||||
})
|
})
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
if (data.data.UserId === 0) {
|
if (data.type === "ErrorNotFound" || data.data.UserId === 0) {
|
||||||
throw new Error();
|
throw new Error();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,7 +101,7 @@ function AddConversationButton() {
|
||||||
const timeout = setTimeout(() => setLoading(true), 500);
|
const timeout = setTimeout(() => setLoading(true), 500);
|
||||||
|
|
||||||
send("AddConversation", {
|
send("AddConversation", {
|
||||||
ChatPartnerName: result.data,
|
ChatPartnerId: user.data.UserId,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
insertContact(user.data.UserId);
|
insertContact(user.data.UserId);
|
||||||
|
|
@ -131,8 +131,12 @@ function AddConversationButton() {
|
||||||
setOpen(value);
|
setOpen(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger render={<Button>Add Conversation</Button>} />
|
<DialogTrigger
|
||||||
<DialogContent>
|
render={({ onClick }) => (
|
||||||
|
<Button onClick={onClick}>Add Conversation</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<DialogContent showCloseButton={false}>
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New Conversation</DialogTitle>
|
<DialogTitle>New Conversation</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
@ -160,7 +164,13 @@ function AddConversationButton() {
|
||||||
{error && (
|
{error && (
|
||||||
<p className="text-sm text-destructive w-full">{error}</p>
|
<p className="text-sm text-destructive w-full">{error}</p>
|
||||||
)}
|
)}
|
||||||
<DialogClose render={<Button variant="outline">Cancel</Button>} />
|
<DialogClose
|
||||||
|
render={({ onClick }) => (
|
||||||
|
<Button onClick={onClick} variant="outline">
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
<Button disabled={loading} type="submit">
|
<Button disabled={loading} type="submit">
|
||||||
{loading && <Loader2 className="animate-spin" />} Continue
|
{loading && <Loader2 className="animate-spin" />} Continue
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -3,8 +3,9 @@ import { type ReactNode } from "react";
|
||||||
import Sidebar from "@/components/sidebar";
|
import Sidebar from "@/components/sidebar";
|
||||||
import Navbar, { MobileNavbar } from "@/components/navbar";
|
import Navbar, { MobileNavbar } from "@/components/navbar";
|
||||||
import { useShowMobileNavbar } from "./useShowMobileNavbar";
|
import { useShowMobileNavbar } from "./useShowMobileNavbar";
|
||||||
|
import CallPopout from "@tensamin/call/popout";
|
||||||
|
|
||||||
import { useIsMobile, cn, SidebarProvider } from "@tensamin/ui";
|
import { useIsMobile, cn, SidebarProvider } from "@methanium/ui";
|
||||||
|
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
|
@ -16,6 +17,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
||||||
<div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
|
<div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
|
||||||
<SidebarProvider className="h-full min-h-0 overflow-hidden">
|
<SidebarProvider className="h-full min-h-0 overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
<CallPopout />
|
||||||
<div
|
<div
|
||||||
// Background of ui that is overlapping with the system ui
|
// Background of ui that is overlapping with the system ui
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import Form from "@/components/screens/login/form";
|
import Form from "@/components/screens/login/form";
|
||||||
import { CreateScreen } from "@tensamin/ui";
|
import { CreateScreen } from "@methanium/ui";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes Page.
|
* Executes Page.
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import { defineConfig, type Plugin } from "vite";
|
||||||
import react from "@vitejs/plugin-react";
|
import react from "@vitejs/plugin-react";
|
||||||
import tailwindcss from "@tailwindcss/vite";
|
import tailwindcss from "@tailwindcss/vite";
|
||||||
import { mtp } from "mtp/vite";
|
import { mtp } from "mtp/vite";
|
||||||
|
import { methaniumUi } from "@methanium/ui/vite";
|
||||||
|
|
||||||
const host = process.env.TAURI_DEV_HOST;
|
const host = process.env.TAURI_DEV_HOST;
|
||||||
const appDir = dirname(fileURLToPath(import.meta.url));
|
const appDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
@ -105,7 +106,7 @@ export default defineConfig({
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
host,
|
host,
|
||||||
port: 1421,
|
clientPort: 3000,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -136,6 +137,7 @@ export default defineConfig({
|
||||||
"@tensamin/markdown",
|
"@tensamin/markdown",
|
||||||
"@tensamin/mtp",
|
"@tensamin/mtp",
|
||||||
"@tensamin/notifications",
|
"@tensamin/notifications",
|
||||||
|
"@tensamin/onboarding",
|
||||||
"@tensamin/shared",
|
"@tensamin/shared",
|
||||||
"@tensamin/shared/data",
|
"@tensamin/shared/data",
|
||||||
"@tensamin/shared/log",
|
"@tensamin/shared/log",
|
||||||
|
|
@ -152,8 +154,9 @@ export default defineConfig({
|
||||||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||||
},
|
},
|
||||||
plugins: [
|
plugins: [
|
||||||
|
methaniumUi({ defaultThemeId: "tensamin" }),
|
||||||
deepFilterAssetHeaders(resolve(appDir, "public")),
|
deepFilterAssetHeaders(resolve(appDir, "public")),
|
||||||
mtp({ typeMaps: resolve(appDir, "../../type-maps.yaml") }),
|
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),
|
||||||
{
|
{
|
||||||
name: "workspace-realpath-resolution",
|
name: "workspace-realpath-resolution",
|
||||||
enforce: "post",
|
enforce: "post",
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,10 @@ import reactHooks from "eslint-plugin-react-hooks";
|
||||||
import * as tsParser from "@typescript-eslint/parser";
|
import * as tsParser from "@typescript-eslint/parser";
|
||||||
import { dirname } from "node:path";
|
import { dirname } from "node:path";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
|
import {
|
||||||
|
noReactNamespaceImport,
|
||||||
|
noWindowLocationReload,
|
||||||
|
} from "./utils/eslint-rules/index.js";
|
||||||
|
|
||||||
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
||||||
|
|
||||||
|
|
@ -28,10 +32,18 @@ export default [
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
"react-hooks": reactHooks,
|
"react-hooks": reactHooks,
|
||||||
|
tensamin: {
|
||||||
|
rules: {
|
||||||
|
"no-react-namespace-import": noReactNamespaceImport,
|
||||||
|
"no-window-location-reload": noWindowLocationReload,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
"react-hooks/set-state-in-effect": "off",
|
"react-hooks/set-state-in-effect": "off",
|
||||||
|
"tensamin/no-react-namespace-import": "error",
|
||||||
|
"tensamin/no-window-location-reload": "error",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -53,6 +53,7 @@
|
||||||
libglvnd
|
libglvnd
|
||||||
libnotify
|
libnotify
|
||||||
libpulseaudio
|
libpulseaudio
|
||||||
|
libsecret
|
||||||
libuuid
|
libuuid
|
||||||
libxkbcommon
|
libxkbcommon
|
||||||
mesa
|
mesa
|
||||||
|
|
@ -104,7 +105,8 @@
|
||||||
cp -r usr/* "$out/"
|
cp -r usr/* "$out/"
|
||||||
|
|
||||||
mkdir -p "$out/bin"
|
mkdir -p "$out/bin"
|
||||||
makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin"
|
makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin" \
|
||||||
|
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}"
|
||||||
|
|
||||||
substituteInPlace "$out/share/applications/Tensamin.desktop" \
|
substituteInPlace "$out/share/applications/Tensamin.desktop" \
|
||||||
--replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin"
|
--replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin"
|
||||||
|
|
@ -172,6 +174,7 @@
|
||||||
libglvnd
|
libglvnd
|
||||||
libnotify
|
libnotify
|
||||||
libpulseaudio
|
libpulseaudio
|
||||||
|
libsecret
|
||||||
libuuid
|
libuuid
|
||||||
libxkbcommon
|
libxkbcommon
|
||||||
mesa
|
mesa
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
The MIT License (MIT)
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2022 Paul Miller (https://paulmillr.com)
|
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the “Software”), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
|
@ -12,7 +12,7 @@ furnished to do so, subject to the following conditions:
|
||||||
The above copyright notice and this permission notice shall be included in
|
The above copyright notice and this permission notice shall be included in
|
||||||
all copies or substantial portions of the Software.
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
The MIT License (MIT)
|
MIT License
|
||||||
|
|
||||||
Copyright (c) 2022 Paul Miller (https://paulmillr.com)
|
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||||
|
|
||||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
of this software and associated documentation files (the “Software”), to deal
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
in the Software without restriction, including without limitation the rights
|
in the Software without restriction, including without limitation the rights
|
||||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
copies of the Software, and to permit persons to whom the Software is
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
|
@ -12,7 +12,7 @@ furnished to do so, subject to the following conditions:
|
||||||
The above copyright notice and this permission notice shall be included in
|
The above copyright notice and this permission notice shall be included in
|
||||||
all copies or substantial portions of the Software.
|
all copies or substantial portions of the Software.
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
22
licenses/@twemoji_api@17.0.3/LICENSE
Normal file
22
licenses/@twemoji_api@17.0.3/LICENSE
Normal file
|
|
@ -0,0 +1,22 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2022–present Jason Sofonia & Justine De Caires
|
||||||
|
Copyright (c) 2014–2021 Twitter
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -31,6 +31,15 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
||||||
- Folder: `licenses/@base-ui_utils@0.3.1`
|
- Folder: `licenses/@base-ui_utils@0.3.1`
|
||||||
- Source package dir: `apps/web/node_modules/@base-ui/utils`
|
- Source package dir: `apps/web/node_modules/@base-ui/utils`
|
||||||
|
|
||||||
|
## @codemirror/autocomplete@6.20.3
|
||||||
|
|
||||||
|
- License: MIT
|
||||||
|
- Repository: git+https://code.haverbeke.berlin/codemirror/autocomplete.git
|
||||||
|
- Description: Autocompletion for the CodeMirror code editor
|
||||||
|
- Included files: LICENSE
|
||||||
|
- Folder: `licenses/@codemirror_autocomplete@6.20.3`
|
||||||
|
- Source package dir: `packages/markdown/node_modules/@codemirror/autocomplete`
|
||||||
|
|
||||||
## @codemirror/commands@6.10.4
|
## @codemirror/commands@6.10.4
|
||||||
|
|
||||||
- License: MIT
|
- License: MIT
|
||||||
|
|
@ -49,6 +58,15 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
||||||
- Folder: `licenses/@codemirror_lang-markdown@6.5.0`
|
- Folder: `licenses/@codemirror_lang-markdown@6.5.0`
|
||||||
- Source package dir: `packages/markdown/node_modules/@codemirror/lang-markdown`
|
- Source package dir: `packages/markdown/node_modules/@codemirror/lang-markdown`
|
||||||
|
|
||||||
|
## @codemirror/language@6.12.4
|
||||||
|
|
||||||
|
- License: MIT
|
||||||
|
- Repository: git+https://code.haverbeke.berlin/codemirror/language.git
|
||||||
|
- Description: Language support infrastructure for the CodeMirror code editor
|
||||||
|
- Included files: LICENSE
|
||||||
|
- Folder: `licenses/@codemirror_language@6.12.4`
|
||||||
|
- Source package dir: `packages/markdown/node_modules/@codemirror/language`
|
||||||
|
|
||||||
## @codemirror/state@6.7.0
|
## @codemirror/state@6.7.0
|
||||||
|
|
||||||
- License: MIT
|
- License: MIT
|
||||||
|
|
@ -145,26 +163,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
||||||
- Folder: `licenses/@livekit_components-react@2.9.21`
|
- Folder: `licenses/@livekit_components-react@2.9.21`
|
||||||
- Source package dir: `packages/call/node_modules/@livekit/components-react`
|
- Source package dir: `packages/call/node_modules/@livekit/components-react`
|
||||||
|
|
||||||
## @noble/curves@2.2.0
|
|
||||||
|
|
||||||
- License: MIT
|
|
||||||
- Homepage: https://paulmillr.com/noble/
|
|
||||||
- Repository: git+https://github.com/paulmillr/noble-curves.git
|
|
||||||
- Description: Audited & minimal JS implementation of elliptic curve cryptography
|
|
||||||
- Included files: LICENSE
|
|
||||||
- Folder: `licenses/@noble_curves@2.2.0`
|
|
||||||
- Source package dir: `packages/crypto/node_modules/@noble/curves`
|
|
||||||
|
|
||||||
## @noble/hashes@2.2.0
|
|
||||||
|
|
||||||
- License: MIT
|
|
||||||
- Homepage: https://paulmillr.com/noble/
|
|
||||||
- Repository: git+https://github.com/paulmillr/noble-hashes.git
|
|
||||||
- Description: Audited & minimal 0-dependency JS implementation of SHA, RIPEMD, BLAKE, HMAC, HKDF, PBKDF & Scrypt
|
|
||||||
- Included files: LICENSE
|
|
||||||
- Folder: `licenses/@noble_hashes@2.2.0`
|
|
||||||
- Source package dir: `packages/crypto/node_modules/@noble/hashes`
|
|
||||||
|
|
||||||
## @radix-ui/primitive@1.1.4
|
## @radix-ui/primitive@1.1.4
|
||||||
|
|
||||||
- License: MIT
|
- License: MIT
|
||||||
|
|
@ -494,13 +492,23 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
||||||
- Folder: `licenses/@tauri-apps_plugin-notification@2.3.3`
|
- Folder: `licenses/@tauri-apps_plugin-notification@2.3.3`
|
||||||
- Source package dir: `apps/tauri/node_modules/@tauri-apps/plugin-notification`
|
- Source package dir: `apps/tauri/node_modules/@tauri-apps/plugin-notification`
|
||||||
|
|
||||||
## @tensamin/ui@0.0.39
|
## @tensamin/ui@0.0.41
|
||||||
|
|
||||||
- License: UNKNOWN
|
- License: UNKNOWN
|
||||||
- Included files: none found
|
- Included files: none found
|
||||||
- Folder: `licenses/@tensamin_ui@0.0.39`
|
- Folder: `licenses/@tensamin_ui@0.0.41`
|
||||||
- Source package dir: `apps/tauri/node_modules/@tensamin/ui`
|
- Source package dir: `apps/tauri/node_modules/@tensamin/ui`
|
||||||
|
|
||||||
|
## @twemoji/api@17.0.3
|
||||||
|
|
||||||
|
- License: MIT AND CC-BY-4.0
|
||||||
|
- Homepage: https://github.com/jdecked/twemoji
|
||||||
|
- Repository: git://github.com/jdecked/twemoji.git
|
||||||
|
- Description: A Unicode standard based way to implement emoji across all platforms.
|
||||||
|
- Included files: LICENSE
|
||||||
|
- Folder: `licenses/@twemoji_api@17.0.3`
|
||||||
|
- Source package dir: `packages/markdown/node_modules/@twemoji/api`
|
||||||
|
|
||||||
## @types/node@25.9.4
|
## @types/node@25.9.4
|
||||||
|
|
||||||
- License: MIT
|
- License: MIT
|
||||||
|
|
@ -599,15 +607,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
||||||
- Folder: `licenses/cmdk@1.1.1`
|
- Folder: `licenses/cmdk@1.1.1`
|
||||||
- Source package dir: `apps/web/node_modules/cmdk`
|
- Source package dir: `apps/web/node_modules/cmdk`
|
||||||
|
|
||||||
## comlink@4.4.2
|
|
||||||
|
|
||||||
- License: Apache-2.0
|
|
||||||
- Repository: https://github.com/GoogleChromeLabs/comlink.git
|
|
||||||
- Description: Comlink makes WebWorkers enjoyable
|
|
||||||
- Included files: LICENSE
|
|
||||||
- Folder: `licenses/comlink@4.4.2`
|
|
||||||
- Source package dir: `packages/crypto/node_modules/comlink`
|
|
||||||
|
|
||||||
## cookie-es@3.1.1
|
## cookie-es@3.1.1
|
||||||
|
|
||||||
- License: MIT
|
- License: MIT
|
||||||
|
|
@ -823,6 +822,15 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
||||||
- Folder: `licenses/embla-carousel-reactive-utils@8.6.0`
|
- Folder: `licenses/embla-carousel-reactive-utils@8.6.0`
|
||||||
- Source package dir: `apps/web/node_modules/embla-carousel-reactive-utils`
|
- Source package dir: `apps/web/node_modules/embla-carousel-reactive-utils`
|
||||||
|
|
||||||
|
## emojibase-data@17.0.0
|
||||||
|
|
||||||
|
- License: MIT
|
||||||
|
- Repository: git@github.com:milesj/emojibase.git
|
||||||
|
- Description: Evergreen emoji datasets.
|
||||||
|
- Included files: LICENSE
|
||||||
|
- Folder: `licenses/emojibase-data@17.0.0`
|
||||||
|
- Source package dir: `packages/markdown/node_modules/emojibase-data`
|
||||||
|
|
||||||
## es-toolkit@1.49.0
|
## es-toolkit@1.49.0
|
||||||
|
|
||||||
- License: MIT
|
- License: MIT
|
||||||
|
|
@ -958,12 +966,21 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
||||||
- Folder: `licenses/lucide-react@1.23.0`
|
- Folder: `licenses/lucide-react@1.23.0`
|
||||||
- Source package dir: `apps/web/node_modules/lucide-react`
|
- Source package dir: `apps/web/node_modules/lucide-react`
|
||||||
|
|
||||||
## mtp@0.1.0
|
## motion@12.42.2
|
||||||
|
|
||||||
|
- License: MIT
|
||||||
|
- Repository: https://github.com/motiondivision/motion
|
||||||
|
- Description: An animation library for JavaScript and React.
|
||||||
|
- Included files: LICENSE.md
|
||||||
|
- Folder: `licenses/motion@12.42.2`
|
||||||
|
- Source package dir: `packages/chat/node_modules/motion`
|
||||||
|
|
||||||
|
## mtp@0.2.0
|
||||||
|
|
||||||
- License: UNKNOWN
|
- License: UNKNOWN
|
||||||
- Description: MTP TypeScript SDK
|
- Description: MTP TypeScript SDK
|
||||||
- Included files: none found
|
- Included files: none found
|
||||||
- Folder: `licenses/mtp@0.1.0`
|
- Folder: `licenses/mtp@0.2.0`
|
||||||
- Source package dir: `packages/mtp/node_modules/mtp`
|
- Source package dir: `packages/mtp/node_modules/mtp`
|
||||||
|
|
||||||
## next-themes@0.4.6
|
## next-themes@0.4.6
|
||||||
|
|
|
||||||
|
|
@ -1,202 +0,0 @@
|
||||||
|
|
||||||
Apache License
|
|
||||||
Version 2.0, January 2004
|
|
||||||
http://www.apache.org/licenses/
|
|
||||||
|
|
||||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
|
||||||
|
|
||||||
1. Definitions.
|
|
||||||
|
|
||||||
"License" shall mean the terms and conditions for use, reproduction,
|
|
||||||
and distribution as defined by Sections 1 through 9 of this document.
|
|
||||||
|
|
||||||
"Licensor" shall mean the copyright owner or entity authorized by
|
|
||||||
the copyright owner that is granting the License.
|
|
||||||
|
|
||||||
"Legal Entity" shall mean the union of the acting entity and all
|
|
||||||
other entities that control, are controlled by, or are under common
|
|
||||||
control with that entity. For the purposes of this definition,
|
|
||||||
"control" means (i) the power, direct or indirect, to cause the
|
|
||||||
direction or management of such entity, whether by contract or
|
|
||||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
|
||||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
|
||||||
|
|
||||||
"You" (or "Your") shall mean an individual or Legal Entity
|
|
||||||
exercising permissions granted by this License.
|
|
||||||
|
|
||||||
"Source" form shall mean the preferred form for making modifications,
|
|
||||||
including but not limited to software source code, documentation
|
|
||||||
source, and configuration files.
|
|
||||||
|
|
||||||
"Object" form shall mean any form resulting from mechanical
|
|
||||||
transformation or translation of a Source form, including but
|
|
||||||
not limited to compiled object code, generated documentation,
|
|
||||||
and conversions to other media types.
|
|
||||||
|
|
||||||
"Work" shall mean the work of authorship, whether in Source or
|
|
||||||
Object form, made available under the License, as indicated by a
|
|
||||||
copyright notice that is included in or attached to the work
|
|
||||||
(an example is provided in the Appendix below).
|
|
||||||
|
|
||||||
"Derivative Works" shall mean any work, whether in Source or Object
|
|
||||||
form, that is based on (or derived from) the Work and for which the
|
|
||||||
editorial revisions, annotations, elaborations, or other modifications
|
|
||||||
represent, as a whole, an original work of authorship. For the purposes
|
|
||||||
of this License, Derivative Works shall not include works that remain
|
|
||||||
separable from, or merely link (or bind by name) to the interfaces of,
|
|
||||||
the Work and Derivative Works thereof.
|
|
||||||
|
|
||||||
"Contribution" shall mean any work of authorship, including
|
|
||||||
the original version of the Work and any modifications or additions
|
|
||||||
to that Work or Derivative Works thereof, that is intentionally
|
|
||||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
|
||||||
or by an individual or Legal Entity authorized to submit on behalf of
|
|
||||||
the copyright owner. For the purposes of this definition, "submitted"
|
|
||||||
means any form of electronic, verbal, or written communication sent
|
|
||||||
to the Licensor or its representatives, including but not limited to
|
|
||||||
communication on electronic mailing lists, source code control systems,
|
|
||||||
and issue tracking systems that are managed by, or on behalf of, the
|
|
||||||
Licensor for the purpose of discussing and improving the Work, but
|
|
||||||
excluding communication that is conspicuously marked or otherwise
|
|
||||||
designated in writing by the copyright owner as "Not a Contribution."
|
|
||||||
|
|
||||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
|
||||||
on behalf of whom a Contribution has been received by Licensor and
|
|
||||||
subsequently incorporated within the Work.
|
|
||||||
|
|
||||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
copyright license to reproduce, prepare Derivative Works of,
|
|
||||||
publicly display, publicly perform, sublicense, and distribute the
|
|
||||||
Work and such Derivative Works in Source or Object form.
|
|
||||||
|
|
||||||
3. Grant of Patent License. Subject to the terms and conditions of
|
|
||||||
this License, each Contributor hereby grants to You a perpetual,
|
|
||||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
|
||||||
(except as stated in this section) patent license to make, have made,
|
|
||||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
|
||||||
where such license applies only to those patent claims licensable
|
|
||||||
by such Contributor that are necessarily infringed by their
|
|
||||||
Contribution(s) alone or by combination of their Contribution(s)
|
|
||||||
with the Work to which such Contribution(s) was submitted. If You
|
|
||||||
institute patent litigation against any entity (including a
|
|
||||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
|
||||||
or a Contribution incorporated within the Work constitutes direct
|
|
||||||
or contributory patent infringement, then any patent licenses
|
|
||||||
granted to You under this License for that Work shall terminate
|
|
||||||
as of the date such litigation is filed.
|
|
||||||
|
|
||||||
4. Redistribution. You may reproduce and distribute copies of the
|
|
||||||
Work or Derivative Works thereof in any medium, with or without
|
|
||||||
modifications, and in Source or Object form, provided that You
|
|
||||||
meet the following conditions:
|
|
||||||
|
|
||||||
(a) You must give any other recipients of the Work or
|
|
||||||
Derivative Works a copy of this License; and
|
|
||||||
|
|
||||||
(b) You must cause any modified files to carry prominent notices
|
|
||||||
stating that You changed the files; and
|
|
||||||
|
|
||||||
(c) You must retain, in the Source form of any Derivative Works
|
|
||||||
that You distribute, all copyright, patent, trademark, and
|
|
||||||
attribution notices from the Source form of the Work,
|
|
||||||
excluding those notices that do not pertain to any part of
|
|
||||||
the Derivative Works; and
|
|
||||||
|
|
||||||
(d) If the Work includes a "NOTICE" text file as part of its
|
|
||||||
distribution, then any Derivative Works that You distribute must
|
|
||||||
include a readable copy of the attribution notices contained
|
|
||||||
within such NOTICE file, excluding those notices that do not
|
|
||||||
pertain to any part of the Derivative Works, in at least one
|
|
||||||
of the following places: within a NOTICE text file distributed
|
|
||||||
as part of the Derivative Works; within the Source form or
|
|
||||||
documentation, if provided along with the Derivative Works; or,
|
|
||||||
within a display generated by the Derivative Works, if and
|
|
||||||
wherever such third-party notices normally appear. The contents
|
|
||||||
of the NOTICE file are for informational purposes only and
|
|
||||||
do not modify the License. You may add Your own attribution
|
|
||||||
notices within Derivative Works that You distribute, alongside
|
|
||||||
or as an addendum to the NOTICE text from the Work, provided
|
|
||||||
that such additional attribution notices cannot be construed
|
|
||||||
as modifying the License.
|
|
||||||
|
|
||||||
You may add Your own copyright statement to Your modifications and
|
|
||||||
may provide additional or different license terms and conditions
|
|
||||||
for use, reproduction, or distribution of Your modifications, or
|
|
||||||
for any such Derivative Works as a whole, provided Your use,
|
|
||||||
reproduction, and distribution of the Work otherwise complies with
|
|
||||||
the conditions stated in this License.
|
|
||||||
|
|
||||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
|
||||||
any Contribution intentionally submitted for inclusion in the Work
|
|
||||||
by You to the Licensor shall be under the terms and conditions of
|
|
||||||
this License, without any additional terms or conditions.
|
|
||||||
Notwithstanding the above, nothing herein shall supersede or modify
|
|
||||||
the terms of any separate license agreement you may have executed
|
|
||||||
with Licensor regarding such Contributions.
|
|
||||||
|
|
||||||
6. Trademarks. This License does not grant permission to use the trade
|
|
||||||
names, trademarks, service marks, or product names of the Licensor,
|
|
||||||
except as required for reasonable and customary use in describing the
|
|
||||||
origin of the Work and reproducing the content of the NOTICE file.
|
|
||||||
|
|
||||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
|
||||||
agreed to in writing, Licensor provides the Work (and each
|
|
||||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
|
||||||
implied, including, without limitation, any warranties or conditions
|
|
||||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
|
||||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
|
||||||
appropriateness of using or redistributing the Work and assume any
|
|
||||||
risks associated with Your exercise of permissions under this License.
|
|
||||||
|
|
||||||
8. Limitation of Liability. In no event and under no legal theory,
|
|
||||||
whether in tort (including negligence), contract, or otherwise,
|
|
||||||
unless required by applicable law (such as deliberate and grossly
|
|
||||||
negligent acts) or agreed to in writing, shall any Contributor be
|
|
||||||
liable to You for damages, including any direct, indirect, special,
|
|
||||||
incidental, or consequential damages of any character arising as a
|
|
||||||
result of this License or out of the use or inability to use the
|
|
||||||
Work (including but not limited to damages for loss of goodwill,
|
|
||||||
work stoppage, computer failure or malfunction, or any and all
|
|
||||||
other commercial damages or losses), even if such Contributor
|
|
||||||
has been advised of the possibility of such damages.
|
|
||||||
|
|
||||||
9. Accepting Warranty or Additional Liability. While redistributing
|
|
||||||
the Work or Derivative Works thereof, You may choose to offer,
|
|
||||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
|
||||||
or other liability obligations and/or rights consistent with this
|
|
||||||
License. However, in accepting such obligations, You may act only
|
|
||||||
on Your own behalf and on Your sole responsibility, not on behalf
|
|
||||||
of any other Contributor, and only if You agree to indemnify,
|
|
||||||
defend, and hold each Contributor harmless for any liability
|
|
||||||
incurred by, or claims asserted against, such Contributor by reason
|
|
||||||
of your accepting any such warranty or additional liability.
|
|
||||||
|
|
||||||
END OF TERMS AND CONDITIONS
|
|
||||||
|
|
||||||
APPENDIX: How to apply the Apache License to your work.
|
|
||||||
|
|
||||||
To apply the Apache License to your work, attach the following
|
|
||||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
|
||||||
replaced with your own identifying information. (Don't include
|
|
||||||
the brackets!) The text should be enclosed in the appropriate
|
|
||||||
comment syntax for the file format. We also recommend that a
|
|
||||||
file or class name and description of purpose be included on the
|
|
||||||
same "printed page" as the copyright notice for easier
|
|
||||||
identification within third-party archives.
|
|
||||||
|
|
||||||
Copyright 2017 Google Inc.
|
|
||||||
|
|
||||||
Licensed under the Apache License, Version 2.0 (the "License");
|
|
||||||
you may not use this file except in compliance with the License.
|
|
||||||
You may obtain a copy of the License at
|
|
||||||
|
|
||||||
http://www.apache.org/licenses/LICENSE-2.0
|
|
||||||
|
|
||||||
Unless required by applicable law or agreed to in writing, software
|
|
||||||
distributed under the License is distributed on an "AS IS" BASIS,
|
|
||||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
||||||
See the License for the specific language governing permissions and
|
|
||||||
limitations under the License.
|
|
||||||
21
licenses/emojibase-data@17.0.0/LICENSE
Normal file
21
licenses/emojibase-data@17.0.0/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2017-2019 Miles Johnson
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
21
licenses/motion@12.42.2/LICENSE.md
Normal file
21
licenses/motion@12.42.2/LICENSE.md
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
The MIT License (MIT)
|
||||||
|
|
||||||
|
Copyright (c) 2024 [Motion](https://motion.dev) B.V.
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
"specVersion": "1.5",
|
"specVersion": "1.5",
|
||||||
"version": 1,
|
"version": 1,
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"timestamp": "2026-07-03T19:50:00.640Z",
|
"timestamp": "2026-07-24T10:43:11.914Z",
|
||||||
"tools": [
|
"tools": [
|
||||||
{
|
{
|
||||||
"vendor": "OpenAI",
|
"vendor": "OpenAI",
|
||||||
|
|
@ -117,6 +117,37 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "library",
|
||||||
|
"bomRef": "pkg:npm/%40codemirror/autocomplete@6.20.3",
|
||||||
|
"name": "@codemirror/autocomplete",
|
||||||
|
"version": "6.20.3",
|
||||||
|
"purl": "pkg:npm/%40codemirror/autocomplete@6.20.3",
|
||||||
|
"description": "Autocompletion for the CodeMirror code editor",
|
||||||
|
"licenses": [
|
||||||
|
{
|
||||||
|
"license": {
|
||||||
|
"id": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"externalReferences": [
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "git+https://code.haverbeke.berlin/codemirror/autocomplete.git"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "local:licenseFolder",
|
||||||
|
"value": "licenses/@codemirror_autocomplete@6.20.3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "local:sourcePackageDir",
|
||||||
|
"value": "packages/markdown/node_modules/@codemirror/autocomplete"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"bomRef": "pkg:npm/%40codemirror/commands@6.10.4",
|
"bomRef": "pkg:npm/%40codemirror/commands@6.10.4",
|
||||||
|
|
@ -179,6 +210,37 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "library",
|
||||||
|
"bomRef": "pkg:npm/%40codemirror/language@6.12.4",
|
||||||
|
"name": "@codemirror/language",
|
||||||
|
"version": "6.12.4",
|
||||||
|
"purl": "pkg:npm/%40codemirror/language@6.12.4",
|
||||||
|
"description": "Language support infrastructure for the CodeMirror code editor",
|
||||||
|
"licenses": [
|
||||||
|
{
|
||||||
|
"license": {
|
||||||
|
"id": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"externalReferences": [
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "git+https://code.haverbeke.berlin/codemirror/language.git"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "local:licenseFolder",
|
||||||
|
"value": "licenses/@codemirror_language@6.12.4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "local:sourcePackageDir",
|
||||||
|
"value": "packages/markdown/node_modules/@codemirror/language"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"bomRef": "pkg:npm/%40codemirror/state@6.7.0",
|
"bomRef": "pkg:npm/%40codemirror/state@6.7.0",
|
||||||
|
|
@ -516,76 +578,6 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "library",
|
|
||||||
"bomRef": "pkg:npm/%40noble/curves@2.2.0",
|
|
||||||
"name": "@noble/curves",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"purl": "pkg:npm/%40noble/curves@2.2.0",
|
|
||||||
"description": "Audited & minimal JS implementation of elliptic curve cryptography",
|
|
||||||
"licenses": [
|
|
||||||
{
|
|
||||||
"license": {
|
|
||||||
"id": "MIT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"externalReferences": [
|
|
||||||
{
|
|
||||||
"type": "website",
|
|
||||||
"url": "https://paulmillr.com/noble/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "vcs",
|
|
||||||
"url": "git+https://github.com/paulmillr/noble-curves.git"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": [
|
|
||||||
{
|
|
||||||
"name": "local:licenseFolder",
|
|
||||||
"value": "licenses/@noble_curves@2.2.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "local:sourcePackageDir",
|
|
||||||
"value": "packages/crypto/node_modules/@noble/curves"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "library",
|
|
||||||
"bomRef": "pkg:npm/%40noble/hashes@2.2.0",
|
|
||||||
"name": "@noble/hashes",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"purl": "pkg:npm/%40noble/hashes@2.2.0",
|
|
||||||
"description": "Audited & minimal 0-dependency JS implementation of SHA, RIPEMD, BLAKE, HMAC, HKDF, PBKDF & Scrypt",
|
|
||||||
"licenses": [
|
|
||||||
{
|
|
||||||
"license": {
|
|
||||||
"id": "MIT"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"externalReferences": [
|
|
||||||
{
|
|
||||||
"type": "website",
|
|
||||||
"url": "https://paulmillr.com/noble/"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"type": "vcs",
|
|
||||||
"url": "git+https://github.com/paulmillr/noble-hashes.git"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": [
|
|
||||||
{
|
|
||||||
"name": "local:licenseFolder",
|
|
||||||
"value": "licenses/@noble_hashes@2.2.0"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "local:sourcePackageDir",
|
|
||||||
"value": "packages/crypto/node_modules/@noble/hashes"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"bomRef": "pkg:npm/%40radix-ui/primitive@1.1.4",
|
"bomRef": "pkg:npm/%40radix-ui/primitive@1.1.4",
|
||||||
|
|
@ -1780,15 +1772,15 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"bomRef": "pkg:npm/%40tensamin/ui@0.0.39",
|
"bomRef": "pkg:npm/%40tensamin/ui@0.0.41",
|
||||||
"name": "@tensamin/ui",
|
"name": "@tensamin/ui",
|
||||||
"version": "0.0.39",
|
"version": "0.0.41",
|
||||||
"purl": "pkg:npm/%40tensamin/ui@0.0.39",
|
"purl": "pkg:npm/%40tensamin/ui@0.0.41",
|
||||||
"externalReferences": [],
|
"externalReferences": [],
|
||||||
"properties": [
|
"properties": [
|
||||||
{
|
{
|
||||||
"name": "local:licenseFolder",
|
"name": "local:licenseFolder",
|
||||||
"value": "licenses/@tensamin_ui@0.0.39"
|
"value": "licenses/@tensamin_ui@0.0.41"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "local:sourcePackageDir",
|
"name": "local:sourcePackageDir",
|
||||||
|
|
@ -1796,6 +1788,41 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "library",
|
||||||
|
"bomRef": "pkg:npm/%40twemoji/api@17.0.3",
|
||||||
|
"name": "@twemoji/api",
|
||||||
|
"version": "17.0.3",
|
||||||
|
"purl": "pkg:npm/%40twemoji/api@17.0.3",
|
||||||
|
"description": "A Unicode standard based way to implement emoji across all platforms.",
|
||||||
|
"licenses": [
|
||||||
|
{
|
||||||
|
"license": {
|
||||||
|
"name": "MIT AND CC-BY-4.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"externalReferences": [
|
||||||
|
{
|
||||||
|
"type": "website",
|
||||||
|
"url": "https://github.com/jdecked/twemoji"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "git://github.com/jdecked/twemoji.git"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "local:licenseFolder",
|
||||||
|
"value": "licenses/@twemoji_api@17.0.3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "local:sourcePackageDir",
|
||||||
|
"value": "packages/markdown/node_modules/@twemoji/api"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"bomRef": "pkg:npm/%40types/node@25.9.4",
|
"bomRef": "pkg:npm/%40types/node@25.9.4",
|
||||||
|
|
@ -2141,37 +2168,6 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"type": "library",
|
|
||||||
"bomRef": "pkg:npm/comlink@4.4.2",
|
|
||||||
"name": "comlink",
|
|
||||||
"version": "4.4.2",
|
|
||||||
"purl": "pkg:npm/comlink@4.4.2",
|
|
||||||
"description": "Comlink makes WebWorkers enjoyable",
|
|
||||||
"licenses": [
|
|
||||||
{
|
|
||||||
"license": {
|
|
||||||
"id": "Apache-2.0"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"externalReferences": [
|
|
||||||
{
|
|
||||||
"type": "vcs",
|
|
||||||
"url": "https://github.com/GoogleChromeLabs/comlink.git"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"properties": [
|
|
||||||
{
|
|
||||||
"name": "local:licenseFolder",
|
|
||||||
"value": "licenses/comlink@4.4.2"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "local:sourcePackageDir",
|
|
||||||
"value": "packages/crypto/node_modules/comlink"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"bomRef": "pkg:npm/cookie-es@3.1.1",
|
"bomRef": "pkg:npm/cookie-es@3.1.1",
|
||||||
|
|
@ -2925,6 +2921,37 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"type": "library",
|
||||||
|
"bomRef": "pkg:npm/emojibase-data@17.0.0",
|
||||||
|
"name": "emojibase-data",
|
||||||
|
"version": "17.0.0",
|
||||||
|
"purl": "pkg:npm/emojibase-data@17.0.0",
|
||||||
|
"description": "Evergreen emoji datasets.",
|
||||||
|
"licenses": [
|
||||||
|
{
|
||||||
|
"license": {
|
||||||
|
"id": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"externalReferences": [
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "git@github.com:milesj/emojibase.git"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "local:licenseFolder",
|
||||||
|
"value": "licenses/emojibase-data@17.0.0"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "local:sourcePackageDir",
|
||||||
|
"value": "packages/markdown/node_modules/emojibase-data"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"bomRef": "pkg:npm/es-toolkit@1.49.0",
|
"bomRef": "pkg:npm/es-toolkit@1.49.0",
|
||||||
|
|
@ -3397,16 +3424,47 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"bomRef": "pkg:npm/mtp@0.1.0",
|
"bomRef": "pkg:npm/motion@12.42.2",
|
||||||
|
"name": "motion",
|
||||||
|
"version": "12.42.2",
|
||||||
|
"purl": "pkg:npm/motion@12.42.2",
|
||||||
|
"description": "An animation library for JavaScript and React.",
|
||||||
|
"licenses": [
|
||||||
|
{
|
||||||
|
"license": {
|
||||||
|
"id": "MIT"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"externalReferences": [
|
||||||
|
{
|
||||||
|
"type": "vcs",
|
||||||
|
"url": "https://github.com/motiondivision/motion"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"properties": [
|
||||||
|
{
|
||||||
|
"name": "local:licenseFolder",
|
||||||
|
"value": "licenses/motion@12.42.2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "local:sourcePackageDir",
|
||||||
|
"value": "packages/chat/node_modules/motion"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "library",
|
||||||
|
"bomRef": "pkg:npm/mtp@0.2.0",
|
||||||
"name": "mtp",
|
"name": "mtp",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"purl": "pkg:npm/mtp@0.1.0",
|
"purl": "pkg:npm/mtp@0.2.0",
|
||||||
"description": "MTP TypeScript SDK",
|
"description": "MTP TypeScript SDK",
|
||||||
"externalReferences": [],
|
"externalReferences": [],
|
||||||
"properties": [
|
"properties": [
|
||||||
{
|
{
|
||||||
"name": "local:licenseFolder",
|
"name": "local:licenseFolder",
|
||||||
"value": "licenses/mtp@0.1.0"
|
"value": "licenses/mtp@0.2.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "local:sourcePackageDir",
|
"name": "local:sourcePackageDir",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"generatedAt": "2026-07-03T19:50:00.637Z",
|
"generatedAt": "2026-07-24T10:43:11.913Z",
|
||||||
"packageCount": 143,
|
"packageCount": 145,
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "@babel/runtime",
|
"name": "@babel/runtime",
|
||||||
|
|
@ -41,6 +41,19 @@
|
||||||
"licenseFolder": "licenses/@base-ui_utils@0.3.1",
|
"licenseFolder": "licenses/@base-ui_utils@0.3.1",
|
||||||
"sourcePackageDir": "apps/web/node_modules/@base-ui/utils"
|
"sourcePackageDir": "apps/web/node_modules/@base-ui/utils"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "@codemirror/autocomplete",
|
||||||
|
"version": "6.20.3",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": null,
|
||||||
|
"repository": "git+https://code.haverbeke.berlin/codemirror/autocomplete.git",
|
||||||
|
"description": "Autocompletion for the CodeMirror code editor",
|
||||||
|
"files": [
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"licenseFolder": "licenses/@codemirror_autocomplete@6.20.3",
|
||||||
|
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/autocomplete"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "@codemirror/commands",
|
"name": "@codemirror/commands",
|
||||||
"version": "6.10.4",
|
"version": "6.10.4",
|
||||||
|
|
@ -67,6 +80,19 @@
|
||||||
"licenseFolder": "licenses/@codemirror_lang-markdown@6.5.0",
|
"licenseFolder": "licenses/@codemirror_lang-markdown@6.5.0",
|
||||||
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/lang-markdown"
|
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/lang-markdown"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "@codemirror/language",
|
||||||
|
"version": "6.12.4",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": null,
|
||||||
|
"repository": "git+https://code.haverbeke.berlin/codemirror/language.git",
|
||||||
|
"description": "Language support infrastructure for the CodeMirror code editor",
|
||||||
|
"files": [
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"licenseFolder": "licenses/@codemirror_language@6.12.4",
|
||||||
|
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/language"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "@codemirror/state",
|
"name": "@codemirror/state",
|
||||||
"version": "6.7.0",
|
"version": "6.7.0",
|
||||||
|
|
@ -197,32 +223,6 @@
|
||||||
"licenseFolder": "licenses/@livekit_components-react@2.9.21",
|
"licenseFolder": "licenses/@livekit_components-react@2.9.21",
|
||||||
"sourcePackageDir": "packages/call/node_modules/@livekit/components-react"
|
"sourcePackageDir": "packages/call/node_modules/@livekit/components-react"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "@noble/curves",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://paulmillr.com/noble/",
|
|
||||||
"repository": "git+https://github.com/paulmillr/noble-curves.git",
|
|
||||||
"description": "Audited & minimal JS implementation of elliptic curve cryptography",
|
|
||||||
"files": [
|
|
||||||
"LICENSE"
|
|
||||||
],
|
|
||||||
"licenseFolder": "licenses/@noble_curves@2.2.0",
|
|
||||||
"sourcePackageDir": "packages/crypto/node_modules/@noble/curves"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"name": "@noble/hashes",
|
|
||||||
"version": "2.2.0",
|
|
||||||
"license": "MIT",
|
|
||||||
"homepage": "https://paulmillr.com/noble/",
|
|
||||||
"repository": "git+https://github.com/paulmillr/noble-hashes.git",
|
|
||||||
"description": "Audited & minimal 0-dependency JS implementation of SHA, RIPEMD, BLAKE, HMAC, HKDF, PBKDF & Scrypt",
|
|
||||||
"files": [
|
|
||||||
"LICENSE"
|
|
||||||
],
|
|
||||||
"licenseFolder": "licenses/@noble_hashes@2.2.0",
|
|
||||||
"sourcePackageDir": "packages/crypto/node_modules/@noble/hashes"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "@radix-ui/primitive",
|
"name": "@radix-ui/primitive",
|
||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
|
|
@ -368,7 +368,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@radix-ui/react-slot",
|
"name": "@radix-ui/react-slot",
|
||||||
"version": "1.2.1",
|
"version": "1.3.0",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"homepage": "https://radix-ui.com/primitives",
|
"homepage": "https://radix-ui.com/primitives",
|
||||||
"repository": "git+https://github.com/radix-ui/primitives.git",
|
"repository": "git+https://github.com/radix-ui/primitives.git",
|
||||||
|
|
@ -682,15 +682,28 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@tensamin/ui",
|
"name": "@tensamin/ui",
|
||||||
"version": "0.0.39",
|
"version": "0.0.41",
|
||||||
"license": "UNKNOWN",
|
"license": "UNKNOWN",
|
||||||
"homepage": null,
|
"homepage": null,
|
||||||
"repository": null,
|
"repository": null,
|
||||||
"description": null,
|
"description": null,
|
||||||
"files": [],
|
"files": [],
|
||||||
"licenseFolder": "licenses/@tensamin_ui@0.0.39",
|
"licenseFolder": "licenses/@tensamin_ui@0.0.41",
|
||||||
"sourcePackageDir": "apps/tauri/node_modules/@tensamin/ui"
|
"sourcePackageDir": "apps/tauri/node_modules/@tensamin/ui"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "@twemoji/api",
|
||||||
|
"version": "17.0.3",
|
||||||
|
"license": "MIT AND CC-BY-4.0",
|
||||||
|
"homepage": "https://github.com/jdecked/twemoji",
|
||||||
|
"repository": "git://github.com/jdecked/twemoji.git",
|
||||||
|
"description": "A Unicode standard based way to implement emoji across all platforms.",
|
||||||
|
"files": [
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"licenseFolder": "licenses/@twemoji_api@17.0.3",
|
||||||
|
"sourcePackageDir": "packages/markdown/node_modules/@twemoji/api"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "@types/node",
|
"name": "@types/node",
|
||||||
"version": "25.9.4",
|
"version": "25.9.4",
|
||||||
|
|
@ -821,19 +834,6 @@
|
||||||
"licenseFolder": "licenses/cmdk@1.1.1",
|
"licenseFolder": "licenses/cmdk@1.1.1",
|
||||||
"sourcePackageDir": "apps/web/node_modules/cmdk"
|
"sourcePackageDir": "apps/web/node_modules/cmdk"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"name": "comlink",
|
|
||||||
"version": "4.4.2",
|
|
||||||
"license": "Apache-2.0",
|
|
||||||
"homepage": null,
|
|
||||||
"repository": "https://github.com/GoogleChromeLabs/comlink.git",
|
|
||||||
"description": "Comlink makes WebWorkers enjoyable",
|
|
||||||
"files": [
|
|
||||||
"LICENSE"
|
|
||||||
],
|
|
||||||
"licenseFolder": "licenses/comlink@4.4.2",
|
|
||||||
"sourcePackageDir": "packages/crypto/node_modules/comlink"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"name": "cookie-es",
|
"name": "cookie-es",
|
||||||
"version": "3.1.1",
|
"version": "3.1.1",
|
||||||
|
|
@ -1018,7 +1018,7 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "deepfilternet3-noise-filter",
|
"name": "deepfilternet3-noise-filter",
|
||||||
"version": "1.3.0",
|
"version": "1.2.1",
|
||||||
"license": "(Apache-2.0 OR MIT)",
|
"license": "(Apache-2.0 OR MIT)",
|
||||||
"homepage": "https://github.com/mezonai/mezon-noise-suppression#readme",
|
"homepage": "https://github.com/mezonai/mezon-noise-suppression#readme",
|
||||||
"repository": "git+https://github.com/mezonai/mezon-noise-suppression.git",
|
"repository": "git+https://github.com/mezonai/mezon-noise-suppression.git",
|
||||||
|
|
@ -1115,6 +1115,19 @@
|
||||||
"licenseFolder": "licenses/embla-carousel-reactive-utils@8.6.0",
|
"licenseFolder": "licenses/embla-carousel-reactive-utils@8.6.0",
|
||||||
"sourcePackageDir": "apps/web/node_modules/embla-carousel-reactive-utils"
|
"sourcePackageDir": "apps/web/node_modules/embla-carousel-reactive-utils"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "emojibase-data",
|
||||||
|
"version": "17.0.0",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": null,
|
||||||
|
"repository": "git@github.com:milesj/emojibase.git",
|
||||||
|
"description": "Evergreen emoji datasets.",
|
||||||
|
"files": [
|
||||||
|
"LICENSE"
|
||||||
|
],
|
||||||
|
"licenseFolder": "licenses/emojibase-data@17.0.0",
|
||||||
|
"sourcePackageDir": "packages/markdown/node_modules/emojibase-data"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "es-toolkit",
|
"name": "es-toolkit",
|
||||||
"version": "1.49.0",
|
"version": "1.49.0",
|
||||||
|
|
@ -1293,15 +1306,28 @@
|
||||||
"licenseFolder": "licenses/lucide-react@1.23.0",
|
"licenseFolder": "licenses/lucide-react@1.23.0",
|
||||||
"sourcePackageDir": "apps/web/node_modules/lucide-react"
|
"sourcePackageDir": "apps/web/node_modules/lucide-react"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "motion",
|
||||||
|
"version": "12.42.2",
|
||||||
|
"license": "MIT",
|
||||||
|
"homepage": null,
|
||||||
|
"repository": "https://github.com/motiondivision/motion",
|
||||||
|
"description": "An animation library for JavaScript and React.",
|
||||||
|
"files": [
|
||||||
|
"LICENSE.md"
|
||||||
|
],
|
||||||
|
"licenseFolder": "licenses/motion@12.42.2",
|
||||||
|
"sourcePackageDir": "packages/chat/node_modules/motion"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "mtp",
|
"name": "mtp",
|
||||||
"version": "0.1.0",
|
"version": "0.2.0",
|
||||||
"license": "UNKNOWN",
|
"license": "UNKNOWN",
|
||||||
"homepage": null,
|
"homepage": null,
|
||||||
"repository": null,
|
"repository": null,
|
||||||
"description": "MTP TypeScript SDK",
|
"description": "MTP TypeScript SDK",
|
||||||
"files": [],
|
"files": [],
|
||||||
"licenseFolder": "licenses/mtp@0.1.0",
|
"licenseFolder": "licenses/mtp@0.2.0",
|
||||||
"sourcePackageDir": "packages/mtp/node_modules/mtp"
|
"sourcePackageDir": "packages/mtp/node_modules/mtp"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
1
mtp-type-maps
Submodule
1
mtp-type-maps
Submodule
|
|
@ -0,0 +1 @@
|
||||||
|
Subproject commit 11a1d79409857b734e948dd8c3e28e6ba721d15f
|
||||||
16
package.json
16
package.json
|
|
@ -10,15 +10,15 @@
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"format": "pnpm exec prettier --write .",
|
"format": "pnpm exec prettier --write .",
|
||||||
"lint": "node scripts/lint-packages.ts",
|
"lint": "node utils/scripts/lint-packages.ts",
|
||||||
"check": "fallow && pnpm run lint",
|
"check": "fallow && pnpm run lint",
|
||||||
"copy-licenses": "node scripts/copy-licenses.ts",
|
"copy-licenses": "node utils/scripts/copy-licenses.ts",
|
||||||
"copy-releases": "node scripts/copy-releases.ts",
|
"copy-releases": "node utils/scripts/copy-releases.ts",
|
||||||
"pre-build": "pnpm run lint && pnpm run build:packages",
|
"pre-build": "pnpm run lint && pnpm run build:packages",
|
||||||
"build:apps": "pnpm run copy-licenses && pnpm run pre-build && pnpm run build:mobile && pnpm run build:desktop && pnpm run copy-releases",
|
"build:apps": "pnpm run copy-licenses && pnpm run pre-build && pnpm run build:mobile && pnpm run build:desktop && pnpm run copy-releases",
|
||||||
"build:packages": "node scripts/build-packages.ts",
|
"build:packages": "node utils/scripts/build-packages.ts",
|
||||||
"update:packages": "node scripts/update-packages.ts",
|
"update:packages": "node utils/scripts/update-packages.ts",
|
||||||
"dev:web": "cd apps/web && pnpm dev",
|
"dev": "cd apps/web && pnpm dev",
|
||||||
"build:web": "cd apps/web && pnpm run build",
|
"build:web": "cd apps/web && pnpm run build",
|
||||||
"preview:web": "cd apps/web && pnpm run preview",
|
"preview:web": "cd apps/web && pnpm run preview",
|
||||||
"dev:mobile": "cd apps/tauri && pnpm dev:mobile",
|
"dev:mobile": "cd apps/tauri && pnpm dev:mobile",
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
|
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
|
||||||
"dev:desktop": "cd apps/electron && pnpm run dev",
|
"dev:desktop": "cd apps/electron && pnpm run dev",
|
||||||
"build:desktop": "cd apps/electron && pnpm run package",
|
"build:desktop": "cd apps/electron && pnpm run package",
|
||||||
"delete:mobile": "cd apps/tauri && nix develop ../..#tauri --command adb uninstall net.tensamin.client"
|
"delete:mobile": "cd apps/tauri && nix develop ../..#tauri --command adb uninstall net.tensamin.client.dev"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
|
|
@ -46,7 +46,7 @@
|
||||||
"yaml": "^2.8.2"
|
"yaml": "^2.8.2"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@tensamin/ui": "*",
|
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.2/methanium-ui.tgz",
|
||||||
"mtp": "*",
|
"mtp": "*",
|
||||||
"sonner": "^2.0.7"
|
"sonner": "^2.0.7"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
21
packages/cache/src/index.ts
vendored
21
packages/cache/src/index.ts
vendored
|
|
@ -175,12 +175,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
|
||||||
const userId = Number(key);
|
const userId = Number(key);
|
||||||
return retained.has(userId)
|
return retained.has(userId)
|
||||||
? []
|
? []
|
||||||
: [
|
: [deleteDatabaseEntry("cache", storedKey("conversations", key))];
|
||||||
deleteDatabaseEntry(
|
|
||||||
"cache",
|
|
||||||
storedKey("conversations", key),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -210,12 +205,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
|
||||||
const userId = Number(key);
|
const userId = Number(key);
|
||||||
return retained.has(userId)
|
return retained.has(userId)
|
||||||
? []
|
? []
|
||||||
: [
|
: [deleteDatabaseEntry("cache", storedKey("conversations", key))];
|
||||||
deleteDatabaseEntry(
|
|
||||||
"cache",
|
|
||||||
storedKey("conversations", key),
|
|
||||||
),
|
|
||||||
];
|
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -241,12 +231,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
|
||||||
storedEntries.flatMap(([key]) =>
|
storedEntries.flatMap(([key]) =>
|
||||||
retained.has(Number(key))
|
retained.has(Number(key))
|
||||||
? []
|
? []
|
||||||
: [
|
: [deleteDatabaseEntry("cache", storedKey("conversations", key))],
|
||||||
deleteDatabaseEntry(
|
|
||||||
"cache",
|
|
||||||
storedKey("conversations", key),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
42
packages/cache/src/sync.tsx
vendored
42
packages/cache/src/sync.tsx
vendored
|
|
@ -18,7 +18,6 @@ export default function CacheSync() {
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const [accountId, setAccountId] = useState(0);
|
const [accountId, setAccountId] = useState(0);
|
||||||
const queueRef = useRef(Promise.resolve());
|
const queueRef = useRef(Promise.resolve());
|
||||||
const reactionPartnersRef = useRef(new Map<number, number>());
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void load("user_id").then(setAccountId);
|
void load("user_id").then(setAccountId);
|
||||||
|
|
@ -143,6 +142,7 @@ export default function CacheSync() {
|
||||||
MessageState: "sent",
|
MessageState: "sent",
|
||||||
SenderId: accountId,
|
SenderId: accountId,
|
||||||
SendTime: Number(request.SendTime),
|
SendTime: Number(request.SendTime),
|
||||||
|
ReplyId: request.ReplyId ? Number(request.ReplyId) : undefined,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -195,23 +195,6 @@ export default function CacheSync() {
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (type === "MessageGet") {
|
|
||||||
const message = result as unknown as CachedMessage;
|
|
||||||
const mappedPartner = reactionPartnersRef.current.get(message.SendTime);
|
|
||||||
reactionPartnersRef.current.delete(message.SendTime);
|
|
||||||
if (mappedPartner) {
|
|
||||||
await insertMessage(mappedPartner, message);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const windows = await secureCache().conversations.list();
|
|
||||||
const window = windows.find((candidate) =>
|
|
||||||
candidate.Messages.some(
|
|
||||||
(cached) => cached.SendTime === message.SendTime,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
if (window) await insertMessage(window.UserId, message);
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
||||||
);
|
);
|
||||||
|
|
@ -259,13 +242,28 @@ export default function CacheSync() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "MessageReactionLive") {
|
if (message.type === "MessageReactionLive") {
|
||||||
reactionPartnersRef.current.set(
|
const partnerId = Number(data.ChatPartnerId);
|
||||||
Number(data.SendTime),
|
const sendTime = Number(data.SendTime);
|
||||||
Number(data.ChatPartnerId),
|
const senderId = Number(data.SenderId);
|
||||||
|
const reaction = String(data.Reaction);
|
||||||
|
const cache = secureCache();
|
||||||
|
const window = await cache.conversations.get(partnerId);
|
||||||
|
const target = window?.Messages.find(
|
||||||
|
(candidate) => candidate.SendTime === sendTime,
|
||||||
);
|
);
|
||||||
|
if (!window || !target) return;
|
||||||
|
|
||||||
|
const reactions = (target.Reactions ?? []).filter(
|
||||||
|
(candidate) =>
|
||||||
|
candidate.SenderId !== senderId || candidate.Reaction !== reaction,
|
||||||
|
);
|
||||||
|
if (data.Accepted === true) {
|
||||||
|
reactions.push({ SenderId: senderId, Reaction: reaction });
|
||||||
|
}
|
||||||
|
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[accountId, insertMessage, removeMessage, replaceMessage],
|
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -19,12 +19,11 @@
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@livekit/components-react": "^2.9.20",
|
"@livekit/components-react": "^2.9.20",
|
||||||
"@tanstack/react-router": "^1.169.1",
|
"@tanstack/react-router": "^1.169.1",
|
||||||
"@tauri-apps/api": "^2",
|
|
||||||
"@tensamin/crypto": "workspace:*",
|
"@tensamin/crypto": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/ui": "*",
|
"@methanium/ui": "*",
|
||||||
"@tensamin/user": "workspace:*",
|
"@tensamin/user": "workspace:*",
|
||||||
"deepfilternet3-noise-filter": "1.2.1",
|
"deepfilternet3-noise-filter": "1.2.1",
|
||||||
"livekit-client": "^2.18.8",
|
"livekit-client": "^2.18.8",
|
||||||
|
|
|
||||||
|
|
@ -5,11 +5,13 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@tensamin/ui";
|
useIsMobile,
|
||||||
|
cn,
|
||||||
|
} from "@methanium/ui";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import MuteButton from "./buttons/mute";
|
import MuteButton from "./buttons/mute";
|
||||||
import DeafButton from "./buttons/deaf";
|
import DeafButton from "./buttons/deaf";
|
||||||
import ScreenshareButton from "./buttons/screenshare";
|
import MediaShareButton from "./buttons/mediaShare";
|
||||||
import LeaveButton from "./buttons/leave";
|
import LeaveButton from "./buttons/leave";
|
||||||
import {
|
import {
|
||||||
setCallIsPopout,
|
setCallIsPopout,
|
||||||
|
|
@ -31,7 +33,7 @@ import {
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
|
|
||||||
export default function Actions() {
|
export default function Actions() {
|
||||||
const sharedClasses = "w-14 h-10";
|
const sharedClasses = "w-14! h-10!";
|
||||||
const sharedIconSize = 15;
|
const sharedIconSize = 15;
|
||||||
const view = useCall((state) => state.view);
|
const view = useCall((state) => state.view);
|
||||||
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
||||||
|
|
@ -77,34 +79,45 @@ export default function Actions() {
|
||||||
(state) => state.usersInFocusedViewHidden,
|
(state) => state.usersInFocusedViewHidden,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="w-full flex justify-between items-center">
|
<div
|
||||||
<div className="w-30 flex justify-start">
|
className={cn(
|
||||||
{view === "focused" && (
|
"w-full flex items-center",
|
||||||
<Tooltip>
|
isMobile ? "justify-center" : "justify-between",
|
||||||
<TooltipTrigger
|
)}
|
||||||
render={
|
>
|
||||||
<Button
|
{!isMobile && (
|
||||||
onClick={() =>
|
<div className="w-30 flex justify-start">
|
||||||
setUsersInFocusedViewHidden(!usersInFocusedViewHidden)
|
{view === "focused" && (
|
||||||
}
|
<Tooltip>
|
||||||
variant="link"
|
<TooltipTrigger
|
||||||
className="w-11 h-11 p-0! ml-3 text-foreground"
|
render={({ ref, onClick }) => (
|
||||||
>
|
<Button
|
||||||
{usersInFocusedViewHidden ? (
|
ref={ref as React.Ref<HTMLButtonElement>}
|
||||||
<ChevronUp className="size-6" />
|
onClick={(event) => {
|
||||||
) : (
|
onClick?.(event);
|
||||||
<ChevronDown className="size-6" />
|
setUsersInFocusedViewHidden(!usersInFocusedViewHidden);
|
||||||
)}
|
}}
|
||||||
</Button>
|
variant="ghost"
|
||||||
}
|
className="w-11 h-11! p-0! ml-3 text-foreground border-0!"
|
||||||
/>
|
>
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
{usersInFocusedViewHidden ? (
|
||||||
Hide users
|
<ChevronUp className="size-6" />
|
||||||
</TooltipContent>
|
) : (
|
||||||
</Tooltip>
|
<ChevronDown className="size-6" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
|
Hide users
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Card className="p-1.75">
|
<Card className="p-1.75">
|
||||||
<CardContent className="p-0! flex gap-1.75">
|
<CardContent className="p-0! flex gap-1.75">
|
||||||
<MuteButton
|
<MuteButton
|
||||||
|
|
@ -119,10 +132,10 @@ export default function Actions() {
|
||||||
tooltip="Deafen"
|
tooltip="Deafen"
|
||||||
portalContainer={portalContainer}
|
portalContainer={portalContainer}
|
||||||
/>
|
/>
|
||||||
<ScreenshareButton
|
<MediaShareButton
|
||||||
className={sharedClasses}
|
className={sharedClasses}
|
||||||
iconSize={sharedIconSize}
|
iconSize={sharedIconSize}
|
||||||
tooltip="Screenshare"
|
tooltip="Share media"
|
||||||
/>
|
/>
|
||||||
<InviteButton
|
<InviteButton
|
||||||
className={sharedClasses}
|
className={sharedClasses}
|
||||||
|
|
@ -132,11 +145,15 @@ export default function Actions() {
|
||||||
{isWatchingFocusedStream && focusedParticipantId !== ownId ? (
|
{isWatchingFocusedStream && focusedParticipantId !== ownId ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={({ ref, onClick }) => (
|
||||||
<Button
|
<Button
|
||||||
|
ref={ref}
|
||||||
className={sharedClasses}
|
className={sharedClasses}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => stopWatchingFocusedStream()}
|
onClick={(event) => {
|
||||||
|
onClick?.(event);
|
||||||
|
stopWatchingFocusedStream();
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ScreenShareOff
|
<ScreenShareOff
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -145,7 +162,7 @@ export default function Actions() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
Stop watching
|
Stop watching
|
||||||
|
|
@ -161,50 +178,58 @@ export default function Actions() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<div className="w-30 flex justify-end">
|
{!isMobile && (
|
||||||
<Tooltip>
|
<div className="w-30 flex justify-end gap-1.5">
|
||||||
<TooltipTrigger
|
<Tooltip>
|
||||||
render={
|
<TooltipTrigger
|
||||||
<Button
|
render={({ ref, onClick }) => (
|
||||||
onClick={() => setCallIsPopout(!callIsPopout)}
|
<Button
|
||||||
variant="link"
|
ref={ref}
|
||||||
className="w-11 h-11 p-0! text-foreground"
|
onClick={(event) => {
|
||||||
>
|
onClick?.(event);
|
||||||
{callIsPopout ? (
|
setCallIsPopout(!callIsPopout);
|
||||||
<SquareArrowOutDownLeft className="size-6" />
|
}}
|
||||||
) : (
|
variant="ghost"
|
||||||
<SquareArrowOutUpRight className="size-6" />
|
className="w-11 h-11! p-0! text-foreground border-0!"
|
||||||
)}
|
>
|
||||||
</Button>
|
{callIsPopout ? (
|
||||||
}
|
<SquareArrowOutDownLeft className="size-6" />
|
||||||
/>
|
) : (
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
<SquareArrowOutUpRight className="size-6" />
|
||||||
Popout
|
)}
|
||||||
</TooltipContent>
|
</Button>
|
||||||
</Tooltip>
|
)}
|
||||||
<Tooltip>
|
/>
|
||||||
<TooltipTrigger
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
render={
|
Popout
|
||||||
<Button
|
</TooltipContent>
|
||||||
onClick={() => {
|
</Tooltip>
|
||||||
void toggleFullscreen();
|
<Tooltip>
|
||||||
}}
|
<TooltipTrigger
|
||||||
variant="link"
|
render={({ ref, onClick }) => (
|
||||||
className="w-11 h-11 p-0! mr-3 text-foreground"
|
<Button
|
||||||
>
|
ref={ref}
|
||||||
{callIsFullscreen ? (
|
onClick={(event) => {
|
||||||
<Minimize className="size-6" />
|
onClick?.(event);
|
||||||
) : (
|
void toggleFullscreen();
|
||||||
<Maximize className="size-6" />
|
}}
|
||||||
)}
|
variant="ghost"
|
||||||
</Button>
|
className="w-11 h-11! p-0! mr-3 text-foreground border-0!"
|
||||||
}
|
>
|
||||||
/>
|
{callIsFullscreen ? (
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
<Minimize className="size-6" />
|
||||||
Fullscreen
|
) : (
|
||||||
</TooltipContent>
|
<Maximize className="size-6" />
|
||||||
</Tooltip>
|
)}
|
||||||
</div>
|
</Button>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
|
Fullscreen
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button } from "@methanium/ui";
|
||||||
import { toggleDeaf, useCall } from "../../store";
|
import { toggleDeaf, useCall } from "../../store";
|
||||||
import { HeadphoneOff, Headphones } from "lucide-react";
|
import { HeadphoneOff, Headphones } from "lucide-react";
|
||||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
import {
|
||||||
|
type CallButtonProps,
|
||||||
|
iconScale,
|
||||||
|
withButtonTooltip,
|
||||||
|
} from "./tooltipButton";
|
||||||
|
|
||||||
export default function DeafButton({
|
export default function DeafButton({
|
||||||
className,
|
className,
|
||||||
|
|
@ -25,5 +29,5 @@ export default function DeafButton({
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return withTooltip(button, tooltip, portalContainer);
|
return withButtonTooltip(button, tooltip, portalContainer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
import { Mail } from "lucide-react";
|
import { Mail } from "lucide-react";
|
||||||
import { sendCallInvite, useCall } from "../../store";
|
import { sendCallInvite, useCall } from "../../store";
|
||||||
|
|
@ -38,13 +38,13 @@ export default function InviteButton({
|
||||||
render={
|
render={
|
||||||
tooltip ? (
|
tooltip ? (
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={({ ref, onClick }) => (
|
||||||
<Button className={className}>
|
<Button ref={ref} onClick={onClick} className={className}>
|
||||||
<Mail
|
<Mail
|
||||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button className={className}>
|
<Button className={className}>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { LeaveIcon } from "@livekit/components-react";
|
import { LeaveIcon } from "@livekit/components-react";
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button } from "@methanium/ui";
|
||||||
import { disconnect, useCall } from "../../store";
|
import { disconnect, useCall } from "../../store";
|
||||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
import {
|
||||||
|
type CallButtonProps,
|
||||||
|
iconScale,
|
||||||
|
withButtonTooltip,
|
||||||
|
} from "./tooltipButton";
|
||||||
|
|
||||||
export default function LeaveButton({
|
export default function LeaveButton({
|
||||||
className,
|
className,
|
||||||
|
|
@ -22,5 +26,5 @@ export default function LeaveButton({
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return withTooltip(button, tooltip, portalContainer);
|
return withButtonTooltip(button, tooltip, portalContainer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
171
packages/call/src/components/buttons/mediaShare.tsx
Normal file
171
packages/call/src/components/buttons/mediaShare.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Popover,
|
||||||
|
PopoverContent,
|
||||||
|
PopoverTrigger,
|
||||||
|
Tooltip,
|
||||||
|
TooltipContent,
|
||||||
|
TooltipTrigger,
|
||||||
|
} from "@methanium/ui";
|
||||||
|
import { MonitorDot, ScreenShare } from "lucide-react";
|
||||||
|
import { toast } from "@tensamin/shared/log";
|
||||||
|
import {
|
||||||
|
startScreenShare,
|
||||||
|
stopCameraShare,
|
||||||
|
stopScreenShare,
|
||||||
|
useCall,
|
||||||
|
} from "../../store";
|
||||||
|
import { getMediaShareAdapter, type MediaShareKind } from "../../mediaShare";
|
||||||
|
import MediaShareDialog from "../mediaShareDialog";
|
||||||
|
|
||||||
|
export default function MediaShareButton({
|
||||||
|
className,
|
||||||
|
iconSize,
|
||||||
|
tooltip,
|
||||||
|
defaultPortal,
|
||||||
|
}: {
|
||||||
|
className?: string;
|
||||||
|
iconSize?: number;
|
||||||
|
tooltip?: string;
|
||||||
|
defaultPortal?: boolean;
|
||||||
|
}) {
|
||||||
|
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
||||||
|
const cameraEnabled = useCall((state) => state.cameraEnabled);
|
||||||
|
const screenRef = useCall((state) => state.screenRef);
|
||||||
|
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
|
||||||
|
const [dialogKind, setDialogKind] = useState<MediaShareKind | null>(null);
|
||||||
|
const [menuOpen, setMenuOpen] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!defaultPortal) setPortalContainer(screenRef?.current ?? undefined);
|
||||||
|
}, [screenRef, defaultPortal]);
|
||||||
|
|
||||||
|
async function beginScreenShare() {
|
||||||
|
try {
|
||||||
|
const capabilities = await getMediaShareAdapter().getCapabilities();
|
||||||
|
if (capabilities.screenPicker === "sources") {
|
||||||
|
setDialogKind("screen");
|
||||||
|
} else {
|
||||||
|
await startScreenShare({ includeAudio: true });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start screen sharing", error);
|
||||||
|
toast(
|
||||||
|
"error",
|
||||||
|
error instanceof Error
|
||||||
|
? error.message
|
||||||
|
: "Failed to start screen sharing.",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(kind: MediaShareKind) {
|
||||||
|
try {
|
||||||
|
await (kind === "screen" ? stopScreenShare() : stopCameraShare());
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to stop ${kind} sharing`, error);
|
||||||
|
toast("error", `Failed to stop ${kind} sharing.`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<Button
|
||||||
|
variant={isScreensharing || cameraEnabled ? "subtleDefault" : "default"}
|
||||||
|
className="w-full! h-full!"
|
||||||
|
>
|
||||||
|
{isScreensharing || cameraEnabled ? (
|
||||||
|
<MonitorDot
|
||||||
|
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<ScreenShare
|
||||||
|
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Tooltip>
|
||||||
|
<Popover onOpenChange={setMenuOpen} open={menuOpen}>
|
||||||
|
<PopoverTrigger
|
||||||
|
render={({ onClick }) =>
|
||||||
|
tooltip ? (
|
||||||
|
<TooltipTrigger
|
||||||
|
render={({ ref }) => (
|
||||||
|
<span
|
||||||
|
ref={ref as React.Ref<HTMLSpanElement>}
|
||||||
|
onClick={onClick}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
{trigger}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<span className={className} onClick={onClick}>
|
||||||
|
{trigger}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<PopoverContent
|
||||||
|
className="flex w-48 flex-col gap-2"
|
||||||
|
portalProps={{
|
||||||
|
container: defaultPortal ? undefined : portalContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
disabled={isScreensharing}
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
void beginScreenShare();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Share screen
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={cameraEnabled}
|
||||||
|
onClick={() => {
|
||||||
|
setMenuOpen(false);
|
||||||
|
setDialogKind("camera");
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Share camera
|
||||||
|
</Button>
|
||||||
|
{isScreensharing ? (
|
||||||
|
<Button variant="destructive" onClick={() => void stop("screen")}>
|
||||||
|
Stop screen sharing
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
{cameraEnabled ? (
|
||||||
|
<Button variant="destructive" onClick={() => void stop("camera")}>
|
||||||
|
Stop camera
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
{tooltip ? (
|
||||||
|
<TooltipContent
|
||||||
|
portalProps={{
|
||||||
|
container: defaultPortal ? undefined : portalContainer,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{tooltip}
|
||||||
|
</TooltipContent>
|
||||||
|
) : null}
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
{dialogKind ? (
|
||||||
|
<MediaShareDialog
|
||||||
|
kind={dialogKind}
|
||||||
|
open
|
||||||
|
onOpenChange={(open) => !open && setDialogKind(null)}
|
||||||
|
portalContainer={portalContainer}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button } from "@methanium/ui";
|
||||||
import { toggleMute, useCall } from "../../store";
|
import { toggleMute, useCall } from "../../store";
|
||||||
import { Mic, MicOff } from "lucide-react";
|
import { Mic, MicOff } from "lucide-react";
|
||||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
import {
|
||||||
|
type CallButtonProps,
|
||||||
|
iconScale,
|
||||||
|
withButtonTooltip,
|
||||||
|
} from "./tooltipButton";
|
||||||
|
|
||||||
export default function MuteButton({
|
export default function MuteButton({
|
||||||
className,
|
className,
|
||||||
|
|
@ -25,5 +29,5 @@ export default function MuteButton({
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return withTooltip(button, tooltip, portalContainer);
|
return withButtonTooltip(button, tooltip, portalContainer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,169 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Popover,
|
|
||||||
PopoverContent,
|
|
||||||
PopoverTrigger,
|
|
||||||
Tooltip,
|
|
||||||
TooltipContent,
|
|
||||||
TooltipTrigger,
|
|
||||||
} from "@tensamin/ui";
|
|
||||||
import { MonitorDot, ScreenShare } from "lucide-react";
|
|
||||||
import { toast } from "@tensamin/shared/log";
|
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
|
||||||
import { setScreenShareEnabled, useCall } from "../../store";
|
|
||||||
import ScreenShareDialog from "../screenshareDialog";
|
|
||||||
|
|
||||||
export default function ScreenshareButton({
|
|
||||||
className,
|
|
||||||
iconSize,
|
|
||||||
tooltip,
|
|
||||||
defaultPortal,
|
|
||||||
}: {
|
|
||||||
className?: string;
|
|
||||||
iconSize?: number;
|
|
||||||
tooltip?: string;
|
|
||||||
defaultPortal?: boolean;
|
|
||||||
}) {
|
|
||||||
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
|
||||||
const screenRef = useCall((state) => state.screenRef);
|
|
||||||
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
|
|
||||||
const [dialogOpen, setDialogOpen] = useState(false);
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (defaultPortal) return;
|
|
||||||
setPortalContainer(screenRef?.current ?? undefined);
|
|
||||||
}, [screenRef, defaultPortal]);
|
|
||||||
|
|
||||||
async function startWebShare() {
|
|
||||||
try {
|
|
||||||
await setScreenShareEnabled(true, {
|
|
||||||
audio: true,
|
|
||||||
systemAudio: "include",
|
|
||||||
surfaceSwitching: "include",
|
|
||||||
video: true,
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to start web screen share", error);
|
|
||||||
toast(
|
|
||||||
"error",
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to start screen sharing.",
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopShare() {
|
|
||||||
try {
|
|
||||||
await setScreenShareEnabled(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to stop screen share", error);
|
|
||||||
toast("error", "Failed to stop screen sharing.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Tooltip>
|
|
||||||
<Popover onOpenChange={setMenuOpen} open={menuOpen}>
|
|
||||||
<PopoverTrigger
|
|
||||||
render={
|
|
||||||
tooltip ? (
|
|
||||||
<TooltipTrigger
|
|
||||||
render={
|
|
||||||
<Button
|
|
||||||
variant={isScreensharing ? "subtleDefault" : "default"}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{isScreensharing ? (
|
|
||||||
<MonitorDot
|
|
||||||
style={{
|
|
||||||
scale: (iconSize ? iconSize + 100 : 100) + "%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ScreenShare
|
|
||||||
style={{
|
|
||||||
scale: (iconSize ? iconSize + 100 : 100) + "%",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<Button
|
|
||||||
variant={isScreensharing ? "subtleDefault" : "default"}
|
|
||||||
className={className}
|
|
||||||
>
|
|
||||||
{isScreensharing ? (
|
|
||||||
<MonitorDot
|
|
||||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<ScreenShare
|
|
||||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<PopoverContent
|
|
||||||
className="flex w-40 flex-col gap-2"
|
|
||||||
portalProps={{
|
|
||||||
container: defaultPortal ? undefined : portalContainer,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Button
|
|
||||||
disabled={isScreensharing}
|
|
||||||
onClick={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
|
|
||||||
if (isTauri()) {
|
|
||||||
setDialogOpen(true);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
void startWebShare();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Start screenshare
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={!isScreensharing}
|
|
||||||
onClick={() => {
|
|
||||||
setMenuOpen(false);
|
|
||||||
void stopShare();
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Stop screenshare
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline">Change quality</Button>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
{tooltip && (
|
|
||||||
<TooltipContent
|
|
||||||
portalProps={{
|
|
||||||
container: defaultPortal ? undefined : portalContainer,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{tooltip}
|
|
||||||
</TooltipContent>
|
|
||||||
)}
|
|
||||||
</Tooltip>
|
|
||||||
|
|
||||||
{isTauri() && (
|
|
||||||
<ScreenShareDialog
|
|
||||||
open={dialogOpen}
|
|
||||||
onOpenChange={setDialogOpen}
|
|
||||||
isScreensharing={isScreensharing}
|
|
||||||
portalContainer={portalContainer}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { type ReactElement } from "react";
|
import { cloneElement } from "react";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
import type { MouseEvent as ReactMouseEvent, ReactElement, Ref } from "react";
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
|
||||||
|
|
||||||
export type CallButtonProps = {
|
export type CallButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -12,20 +13,31 @@ export function iconScale(iconSize?: number) {
|
||||||
return { scale: (iconSize ? iconSize + 100 : 100) + "%" };
|
return { scale: (iconSize ? iconSize + 100 : 100) + "%" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withTooltip(
|
export function withButtonTooltip(
|
||||||
button: ReactElement,
|
trigger: ReactElement<{
|
||||||
tooltip?: string,
|
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||||
|
ref?: Ref<HTMLElement>;
|
||||||
|
}>,
|
||||||
|
content?: string,
|
||||||
portalContainer?: HTMLElement,
|
portalContainer?: HTMLElement,
|
||||||
) {
|
) {
|
||||||
if (!tooltip) {
|
if (!content) return trigger;
|
||||||
return button;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={button} />
|
<TooltipTrigger
|
||||||
|
render={({ ref, onClick }) =>
|
||||||
|
cloneElement(trigger, {
|
||||||
|
ref,
|
||||||
|
onClick: (event) => {
|
||||||
|
onClick?.(event);
|
||||||
|
trigger.props.onClick?.(event);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
{tooltip}
|
{content}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
import { PhoneIncoming, X } from "lucide-react";
|
import { PhoneIncoming, X } from "lucide-react";
|
||||||
|
|
||||||
|
|
@ -26,7 +26,10 @@ export default function InvitePopup({
|
||||||
loading={null}
|
loading={null}
|
||||||
component={(user) => (
|
component={(user) => (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="flex flex-col gap-5 items-center justify-center w-65 h-80">
|
<DialogContent
|
||||||
|
showCloseButton={false}
|
||||||
|
className="flex flex-col gap-5 items-center justify-center w-65 h-80"
|
||||||
|
>
|
||||||
<Avatar className="size-30">
|
<Avatar className="size-30">
|
||||||
<AvatarImage src={user.Avatar} />
|
<AvatarImage src={user.Avatar} />
|
||||||
<AvatarFallback className="text-5xl">
|
<AvatarFallback className="text-5xl">
|
||||||
|
|
@ -36,14 +39,14 @@ export default function InvitePopup({
|
||||||
<p className="text-xl font-medium">{user.Display}</p>
|
<p className="text-xl font-medium">{user.Display}</p>
|
||||||
<div className="w-full flex justify-center gap-3">
|
<div className="w-full flex justify-center gap-3">
|
||||||
<Button
|
<Button
|
||||||
className="w-14 h-14"
|
className="w-13 h-13!"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => onAccept(false)}
|
onClick={() => onAccept(false)}
|
||||||
>
|
>
|
||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="w-14 h-14"
|
className="w-13 h-13!"
|
||||||
variant="subtleDefault"
|
variant="subtleDefault"
|
||||||
onClick={() => onAccept(true)}
|
onClick={() => onAccept(true)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
223
packages/call/src/components/mediaShareDialog.tsx
Normal file
223
packages/call/src/components/mediaShareDialog.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
Label,
|
||||||
|
Switch,
|
||||||
|
} from "@methanium/ui";
|
||||||
|
import { toast } from "@tensamin/shared/log";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import {
|
||||||
|
getMediaShareAdapter,
|
||||||
|
type MediaShareKind,
|
||||||
|
type MediaShareSource,
|
||||||
|
} from "../mediaShare";
|
||||||
|
import { startCameraShare, startScreenShare } from "../store";
|
||||||
|
import MediaSourceCard from "./mediaSourceCard";
|
||||||
|
|
||||||
|
export default function MediaShareDialog({
|
||||||
|
kind,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
portalContainer,
|
||||||
|
}: {
|
||||||
|
kind: MediaShareKind;
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
portalContainer?: HTMLElement;
|
||||||
|
}) {
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [sources, setSources] = useState<MediaShareSource[]>([]);
|
||||||
|
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
|
||||||
|
const [shareAudio, setShareAudio] = useState(true);
|
||||||
|
const [canShareAudio, setCanShareAudio] = useState(false);
|
||||||
|
const [cameraPreview, setCameraPreview] = useState<MediaStream | null>(null);
|
||||||
|
const [cameraPreviewVersion, setCameraPreviewVersion] = useState(0);
|
||||||
|
const cameraPreviewRef = useRef<MediaStream | null>(null);
|
||||||
|
|
||||||
|
function stopCameraPreview() {
|
||||||
|
cameraPreviewRef.current?.getTracks().forEach((track) => track.stop());
|
||||||
|
cameraPreviewRef.current = null;
|
||||||
|
setCameraPreview(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
let active = true;
|
||||||
|
setLoading(true);
|
||||||
|
setSelectedSourceId(null);
|
||||||
|
|
||||||
|
Promise.all([
|
||||||
|
getMediaShareAdapter().listSources(kind),
|
||||||
|
getMediaShareAdapter().getCapabilities(),
|
||||||
|
])
|
||||||
|
.then(([nextSources, capabilities]) => {
|
||||||
|
if (!active) return;
|
||||||
|
const availableSources =
|
||||||
|
kind === "camera" && nextSources.length === 0
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "__default_camera__",
|
||||||
|
kind: "camera" as const,
|
||||||
|
name: "Default camera",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: nextSources;
|
||||||
|
setSources(availableSources);
|
||||||
|
setSelectedSourceId(availableSources[0]?.id ?? null);
|
||||||
|
setCanShareAudio(kind === "screen" && capabilities.canShareScreenAudio);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to load media share sources", error);
|
||||||
|
toast("error", "Failed to load media sources.");
|
||||||
|
})
|
||||||
|
.finally(() => active && setLoading(false));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [kind, open]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (kind !== "camera" || !open || !selectedSourceId) {
|
||||||
|
stopCameraPreview();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
stopCameraPreview();
|
||||||
|
const sourceId =
|
||||||
|
selectedSourceId === "__default_camera__" ? undefined : selectedSourceId;
|
||||||
|
|
||||||
|
void navigator.mediaDevices
|
||||||
|
.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: sourceId ? { deviceId: { exact: sourceId } } : true,
|
||||||
|
})
|
||||||
|
.then((stream) => {
|
||||||
|
if (!active) {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
cameraPreviewRef.current = stream;
|
||||||
|
setCameraPreview(stream);
|
||||||
|
})
|
||||||
|
.catch((error) => {
|
||||||
|
console.error("Failed to preview camera", error);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
stopCameraPreview();
|
||||||
|
};
|
||||||
|
}, [cameraPreviewVersion, kind, open, selectedSourceId]);
|
||||||
|
|
||||||
|
async function startSharing() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
if (kind === "camera") {
|
||||||
|
stopCameraPreview();
|
||||||
|
await startCameraShare(
|
||||||
|
selectedSourceId === "__default_camera__"
|
||||||
|
? undefined
|
||||||
|
: (selectedSourceId ?? undefined),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
await startScreenShare({
|
||||||
|
sourceId: selectedSourceId ?? undefined,
|
||||||
|
includeAudio: canShareAudio && shareAudio,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
onOpenChange(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error(`Failed to share ${kind}`, error);
|
||||||
|
if (kind === "camera") {
|
||||||
|
setCameraPreviewVersion((version) => version + 1);
|
||||||
|
}
|
||||||
|
toast(
|
||||||
|
"error",
|
||||||
|
error instanceof Error ? error.message : `Failed to share ${kind}.`,
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent
|
||||||
|
className="max-w-[90%] gap-0 overflow-hidden p-0"
|
||||||
|
portalProps={{ container: portalContainer }}
|
||||||
|
>
|
||||||
|
<DialogHeader className="p-4 pb-3">
|
||||||
|
<DialogTitle>
|
||||||
|
{kind === "camera" ? "Share a camera" : "Share your screen"}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex max-h-[75vh] flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||||
|
<div className="grid gap-3 sm:grid-cols-2">
|
||||||
|
{sources.map((source) => {
|
||||||
|
const selected = source.id === selectedSourceId;
|
||||||
|
return (
|
||||||
|
<MediaSourceCard
|
||||||
|
key={source.id}
|
||||||
|
source={source}
|
||||||
|
selected={selected}
|
||||||
|
previewStream={selected ? cameraPreview : null}
|
||||||
|
onSelect={() => setSelectedSourceId(source.id)}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && sources.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||||
|
No {kind === "camera" ? "cameras" : "windows or displays"} found.
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{canShareAudio ? (
|
||||||
|
<div className="flex items-center justify-between gap-4 rounded-xl border p-4">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="share-system-audio">Share audio</Label>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Some apps and protected media do not allow audio capture.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="share-system-audio"
|
||||||
|
checked={shareAudio}
|
||||||
|
onCheckedChange={setShareAudio}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
Loading sources...
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter className="m-0! p-2!">
|
||||||
|
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={loading || sources.length === 0 || !selectedSourceId}
|
||||||
|
onClick={startSharing}
|
||||||
|
>
|
||||||
|
{loading ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||||
|
Start sharing
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
77
packages/call/src/components/mediaSourceCard.tsx
Normal file
77
packages/call/src/components/mediaSourceCard.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
import { cn } from "@methanium/ui";
|
||||||
|
import { AppWindow, Camera, MonitorUp } from "lucide-react";
|
||||||
|
import { useEffect, useRef } from "react";
|
||||||
|
import type { MediaShareSource } from "../mediaShare";
|
||||||
|
|
||||||
|
function SourceIcon({ source }: { source: MediaShareSource }) {
|
||||||
|
const className = "size-8 text-muted-foreground";
|
||||||
|
|
||||||
|
if (source.kind === "camera") return <Camera className={className} />;
|
||||||
|
if (source.kind === "window") return <AppWindow className={className} />;
|
||||||
|
return <MonitorUp className={className} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
function VideoPreview({ stream }: { stream: MediaStream }) {
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const video = videoRef.current;
|
||||||
|
if (!video) return;
|
||||||
|
video.srcObject = stream;
|
||||||
|
void video.play().catch(() => undefined);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
video.srcObject = null;
|
||||||
|
};
|
||||||
|
}, [stream]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<video
|
||||||
|
ref={videoRef}
|
||||||
|
muted
|
||||||
|
autoPlay
|
||||||
|
playsInline
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MediaSourceCard({
|
||||||
|
source,
|
||||||
|
selected,
|
||||||
|
previewStream,
|
||||||
|
onSelect,
|
||||||
|
}: {
|
||||||
|
source: MediaShareSource;
|
||||||
|
selected: boolean;
|
||||||
|
previewStream?: MediaStream | null;
|
||||||
|
onSelect: () => void;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
aria-pressed={selected}
|
||||||
|
onClick={onSelect}
|
||||||
|
className={cn(
|
||||||
|
"overflow-hidden rounded-xl border bg-card text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||||
|
selected
|
||||||
|
? "border-4 border-(--primary)!"
|
||||||
|
: "border-border hover:border-primary/50 hover:bg-muted/50",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="flex aspect-video w-full items-center justify-center overflow-hidden bg-muted">
|
||||||
|
{previewStream ? (
|
||||||
|
<VideoPreview stream={previewStream} />
|
||||||
|
) : source.thumbnail ? (
|
||||||
|
<img
|
||||||
|
src={source.thumbnail}
|
||||||
|
alt=""
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<SourceIcon source={source} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -5,7 +5,8 @@ import {
|
||||||
Button,
|
Button,
|
||||||
ContextMenu as UIContextMenu,
|
ContextMenu as UIContextMenu,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@tensamin/ui";
|
cn,
|
||||||
|
} from "@methanium/ui";
|
||||||
import {
|
import {
|
||||||
focusParticipant,
|
focusParticipant,
|
||||||
getRoomMetadata,
|
getRoomMetadata,
|
||||||
|
|
@ -43,7 +44,7 @@ function TransparentButton({ children }: { children: React.ReactNode }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getAverageImageColor(src: string) {
|
export function getAverageImageColor(src: string) {
|
||||||
return new Promise<string | undefined>((resolve) => {
|
return new Promise<string | undefined>((resolve) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
|
|
||||||
|
|
@ -177,6 +178,13 @@ export default function Base({
|
||||||
Track.Source.ScreenShare,
|
Track.Source.ScreenShare,
|
||||||
);
|
);
|
||||||
const screenSharePreview = participant?.attributes["screenSharePreview"];
|
const screenSharePreview = participant?.attributes["screenSharePreview"];
|
||||||
|
const cameraPublication = getTrackPublicationBySource(
|
||||||
|
participant,
|
||||||
|
Track.Source.Camera,
|
||||||
|
);
|
||||||
|
const cameraDisabled = useCall((state) =>
|
||||||
|
user ? state.disabledCameraParticipantIds.includes(user.UserId) : false,
|
||||||
|
);
|
||||||
|
|
||||||
const [ownId, setOwnId] = useState(0);
|
const [ownId, setOwnId] = useState(0);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -264,9 +272,14 @@ export default function Base({
|
||||||
render={
|
render={
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`relative w-full ${fill ? "h-full border-0" : "aspect-video border-2"} ${
|
className={cn(
|
||||||
flush || fill ? "rounded-none" : "rounded-md"
|
"relative w-full",
|
||||||
} ${flush && !fill ? "border-x-0" : ""}`}
|
fill ? "h-full" : "aspect-video",
|
||||||
|
flush && !fill && "border-x-0!",
|
||||||
|
type === "user" &&
|
||||||
|
isSpeaking &&
|
||||||
|
"border-4 border-(--primary-foreground-alt)/75! rounded-lg",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
<div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2">
|
<div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2">
|
||||||
<>
|
<>
|
||||||
|
|
@ -303,8 +316,8 @@ export default function Base({
|
||||||
<div
|
<div
|
||||||
ref={currentCard}
|
ref={currentCard}
|
||||||
className={`transition-all duration-150 z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${
|
className={`transition-all duration-150 z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${
|
||||||
flush ? "rounded-none" : "rounded-sm"
|
flush ? "rounded-none" : "rounded-lg"
|
||||||
} ${type === "user" && isSpeaking ? "border-4 border-(--primary-foreground-alt)/75" : "border-0"}`}
|
}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: avatarBackgroundColor,
|
backgroundColor: avatarBackgroundColor,
|
||||||
containerType: "size",
|
containerType: "size",
|
||||||
|
|
@ -335,23 +348,31 @@ export default function Base({
|
||||||
</div>
|
</div>
|
||||||
) : null)}
|
) : null)}
|
||||||
|
|
||||||
{type === "user" && (
|
{type === "user" &&
|
||||||
<Avatar
|
(cameraPublication?.track && !cameraDisabled ? (
|
||||||
style={{
|
<VideoViewer
|
||||||
width: "32cqh",
|
fill={fill}
|
||||||
height: "32cqh",
|
flush={flush}
|
||||||
}}
|
participantId={participant.identity}
|
||||||
>
|
publication={cameraPublication}
|
||||||
<AvatarImage src={user.Avatar} />
|
/>
|
||||||
<AvatarFallback
|
) : (
|
||||||
|
<Avatar
|
||||||
style={{
|
style={{
|
||||||
fontSize: "11cqh",
|
width: "32cqh",
|
||||||
|
height: "32cqh",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{user.Display.slice(0, 2).toUpperCase()}
|
<AvatarImage src={user.Avatar} />
|
||||||
</AvatarFallback>
|
<AvatarFallback
|
||||||
</Avatar>
|
style={{
|
||||||
)}
|
fontSize: "11cqh",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{user.Display.slice(0, 2).toUpperCase()}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,9 @@ import {
|
||||||
ContextMenuItem,
|
ContextMenuItem,
|
||||||
ContextMenuSeparator,
|
ContextMenuSeparator,
|
||||||
Slider,
|
Slider,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
import { useCall } from "../../store";
|
import { setParticipantCameraDisabled, useCall } from "../../store";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
|
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
|
||||||
|
|
@ -33,6 +33,9 @@ export default function ContextMenu({
|
||||||
const watchedStreamParticipantIds = useCall(
|
const watchedStreamParticipantIds = useCall(
|
||||||
(state) => state.watchedStreamParticipantIds,
|
(state) => state.watchedStreamParticipantIds,
|
||||||
);
|
);
|
||||||
|
const cameraDisabled = useCall((state) =>
|
||||||
|
state.disabledCameraParticipantIds.includes(user.UserId),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ContextMenuContent className="p-1">
|
<ContextMenuContent className="p-1">
|
||||||
|
|
@ -71,6 +74,19 @@ export default function ContextMenu({
|
||||||
<p>Mute Soundboard</p>
|
<p>Mute Soundboard</p>
|
||||||
<EmptyCheckboxIndicator checked={soundboardMuted} />
|
<EmptyCheckboxIndicator checked={soundboardMuted} />
|
||||||
</ContextMenuCheckboxItem>
|
</ContextMenuCheckboxItem>
|
||||||
|
{user.UserId !== ownId ? (
|
||||||
|
<ContextMenuCheckboxItem
|
||||||
|
checked={cameraDisabled}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setParticipantCameraDisabled(user.UserId, checked)
|
||||||
|
}
|
||||||
|
onSelect={(e) => e.preventDefault()}
|
||||||
|
className="flex justify-between"
|
||||||
|
>
|
||||||
|
<p>Disable camera</p>
|
||||||
|
<EmptyCheckboxIndicator checked={cameraDisabled} />
|
||||||
|
</ContextMenuCheckboxItem>
|
||||||
|
) : null}
|
||||||
<ContextMenuCheckboxItem
|
<ContextMenuCheckboxItem
|
||||||
checked={serverDeafened}
|
checked={serverDeafened}
|
||||||
onCheckedChange={setServerDeafened}
|
onCheckedChange={setServerDeafened}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,22 @@
|
||||||
import { Participant, Track } from "livekit-client";
|
import { Participant, Track } from "livekit-client";
|
||||||
import VideoViewer from "./videoViewer";
|
import VideoViewer from "./videoViewer";
|
||||||
import { useLocation } from "@tanstack/react-router";
|
import { useLocation } from "@tanstack/react-router";
|
||||||
import { getRoom, stopWatchingStream, useCall } from "../store";
|
import { getRoom, openCallPage, stopWatchingStream, useCall } from "../store";
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import { Button, cn } from "@tensamin/ui";
|
import {
|
||||||
|
Avatar,
|
||||||
|
AvatarFallback,
|
||||||
|
AvatarImage,
|
||||||
|
Button,
|
||||||
|
Card,
|
||||||
|
cn,
|
||||||
|
useIsMobile,
|
||||||
|
useSidebar,
|
||||||
|
} from "@methanium/ui";
|
||||||
import { ScreenShareOff } from "lucide-react";
|
import { ScreenShareOff } from "lucide-react";
|
||||||
|
import { useUser, type User } from "@tensamin/user/context";
|
||||||
|
import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState";
|
||||||
|
import { getAverageImageColor } from "./modals/base";
|
||||||
|
|
||||||
function getTrackPublicationBySource(
|
function getTrackPublicationBySource(
|
||||||
participant: Participant | undefined,
|
participant: Participant | undefined,
|
||||||
|
|
@ -38,6 +50,237 @@ const MARGIN = 40;
|
||||||
const MIN_SIZE = 240;
|
const MIN_SIZE = 240;
|
||||||
const MAX_SIZE = 1000;
|
const MAX_SIZE = 1000;
|
||||||
const ASPECT_RATIO = 9 / 16;
|
const ASPECT_RATIO = 9 / 16;
|
||||||
|
const MOBILE_MARGIN = 16;
|
||||||
|
const MOBILE_PILL_WIDTH = 128;
|
||||||
|
const MOBILE_PILL_HEIGHT = 48;
|
||||||
|
|
||||||
|
function MobileCallPill({
|
||||||
|
active,
|
||||||
|
callId,
|
||||||
|
}: {
|
||||||
|
active: boolean;
|
||||||
|
callId: string;
|
||||||
|
}) {
|
||||||
|
const { setOpenMobile } = useSidebar();
|
||||||
|
const { get } = useUser();
|
||||||
|
const lastSpeakingParticipantId = useLastSpeakingParticipantId();
|
||||||
|
const isSpeaking = useIsSpeaking(lastSpeakingParticipantId ?? -1);
|
||||||
|
const [lastSpeakingUser, setLastSpeakingUser] = useState<User | null>(null);
|
||||||
|
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
|
||||||
|
string | undefined
|
||||||
|
>(undefined);
|
||||||
|
const safeAreaRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pillRef = useRef<HTMLDivElement>(null);
|
||||||
|
const initialCoords = {
|
||||||
|
x: window.innerWidth - MOBILE_PILL_WIDTH - MOBILE_MARGIN,
|
||||||
|
y: MOBILE_MARGIN,
|
||||||
|
};
|
||||||
|
const coordsRef = useRef<Point>(initialCoords);
|
||||||
|
const dragOffsetRef = useRef<Point>({ x: 0, y: 0 });
|
||||||
|
const dragStartRef = useRef<Point>({ x: 0, y: 0 });
|
||||||
|
const movedRef = useRef(false);
|
||||||
|
const [position, setPosition] = useState<Positions>("top-right");
|
||||||
|
const [coords, setCoords] = useState(initialCoords);
|
||||||
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastSpeakingParticipantId == null) {
|
||||||
|
setLastSpeakingUser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
void get(lastSpeakingParticipantId).then((user) => {
|
||||||
|
if (mounted) {
|
||||||
|
setLastSpeakingUser(user);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [get, lastSpeakingParticipantId]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!lastSpeakingUser?.Avatar) {
|
||||||
|
setAvatarBackgroundColor(undefined);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
void getAverageImageColor(lastSpeakingUser.Avatar).then((color) => {
|
||||||
|
if (mounted) {
|
||||||
|
setAvatarBackgroundColor(color);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, [lastSpeakingUser?.Avatar]);
|
||||||
|
|
||||||
|
const getSafeArea = useCallback(() => {
|
||||||
|
const element = safeAreaRef.current;
|
||||||
|
if (!element) return { top: 0, right: 0, bottom: 0, left: 0 };
|
||||||
|
const style = getComputedStyle(element);
|
||||||
|
return {
|
||||||
|
top: parseFloat(style.paddingTop) || 0,
|
||||||
|
right: parseFloat(style.paddingRight) || 0,
|
||||||
|
bottom: parseFloat(style.paddingBottom) || 0,
|
||||||
|
left: parseFloat(style.paddingLeft) || 0,
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const getCoordsForPosition = useCallback(
|
||||||
|
(nextPosition: Positions): Point => {
|
||||||
|
const bounds = pillRef.current?.getBoundingClientRect();
|
||||||
|
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
|
||||||
|
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
|
||||||
|
const safeArea = getSafeArea();
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: nextPosition.endsWith("right")
|
||||||
|
? window.innerWidth - safeArea.right - width - MOBILE_MARGIN
|
||||||
|
: safeArea.left + MOBILE_MARGIN,
|
||||||
|
y: nextPosition.startsWith("bottom")
|
||||||
|
? window.innerHeight - safeArea.bottom - height - MOBILE_MARGIN
|
||||||
|
: safeArea.top + MOBILE_MARGIN,
|
||||||
|
};
|
||||||
|
},
|
||||||
|
[getSafeArea],
|
||||||
|
);
|
||||||
|
|
||||||
|
const setCoordsSafe = useCallback((next: Point) => {
|
||||||
|
coordsRef.current = next;
|
||||||
|
setCoords(next);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const snapToPosition = useCallback(
|
||||||
|
(nextPosition: Positions) => {
|
||||||
|
setPosition(nextPosition);
|
||||||
|
setCoordsSafe(getCoordsForPosition(nextPosition));
|
||||||
|
},
|
||||||
|
[getCoordsForPosition, setCoordsSafe],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (active && !isDragging) {
|
||||||
|
snapToPosition(position);
|
||||||
|
}
|
||||||
|
}, [active, isDragging, position, snapToPosition]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const handleResize = () => snapToPosition(position);
|
||||||
|
|
||||||
|
window.addEventListener("resize", handleResize);
|
||||||
|
return () => window.removeEventListener("resize", handleResize);
|
||||||
|
}, [position, snapToPosition]);
|
||||||
|
|
||||||
|
if (!active) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={safeAreaRef}
|
||||||
|
className="pointer-events-none fixed inset-0 z-200 pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]"
|
||||||
|
>
|
||||||
|
<Card
|
||||||
|
ref={pillRef}
|
||||||
|
onClick={() => {
|
||||||
|
if (movedRef.current) {
|
||||||
|
movedRef.current = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setOpenMobile(false);
|
||||||
|
void openCallPage(callId);
|
||||||
|
}}
|
||||||
|
onPointerDown={(event) => {
|
||||||
|
if (event.button !== 0) return;
|
||||||
|
|
||||||
|
event.currentTarget.setPointerCapture(event.pointerId);
|
||||||
|
dragOffsetRef.current = {
|
||||||
|
x: event.clientX - coordsRef.current.x,
|
||||||
|
y: event.clientY - coordsRef.current.y,
|
||||||
|
};
|
||||||
|
dragStartRef.current = { x: event.clientX, y: event.clientY };
|
||||||
|
movedRef.current = false;
|
||||||
|
setIsDragging(true);
|
||||||
|
}}
|
||||||
|
onPointerMove={(event) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const bounds = pillRef.current?.getBoundingClientRect();
|
||||||
|
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
|
||||||
|
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
|
||||||
|
const safeArea = getSafeArea();
|
||||||
|
const next = {
|
||||||
|
x: Math.min(
|
||||||
|
window.innerWidth - safeArea.right - width - MOBILE_MARGIN,
|
||||||
|
Math.max(
|
||||||
|
safeArea.left + MOBILE_MARGIN,
|
||||||
|
event.clientX - dragOffsetRef.current.x,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
y: Math.min(
|
||||||
|
window.innerHeight - safeArea.bottom - height - MOBILE_MARGIN,
|
||||||
|
Math.max(
|
||||||
|
safeArea.top + MOBILE_MARGIN,
|
||||||
|
event.clientY - dragOffsetRef.current.y,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
Math.abs(event.clientX - dragStartRef.current.x) > 3 ||
|
||||||
|
Math.abs(event.clientY - dragStartRef.current.y) > 3
|
||||||
|
) {
|
||||||
|
movedRef.current = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCoordsSafe(next);
|
||||||
|
}}
|
||||||
|
onPointerUp={(event) => {
|
||||||
|
if (!isDragging) return;
|
||||||
|
|
||||||
|
const nextPosition = `${
|
||||||
|
event.clientY < window.innerHeight / 2 ? "top" : "bottom"
|
||||||
|
}-${event.clientX < window.innerWidth / 2 ? "left" : "right"}` as Positions;
|
||||||
|
|
||||||
|
setIsDragging(false);
|
||||||
|
snapToPosition(nextPosition);
|
||||||
|
}}
|
||||||
|
onPointerCancel={() => {
|
||||||
|
setIsDragging(false);
|
||||||
|
snapToPosition(position);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"pointer-events-auto fixed left-0 top-0 z-200 flex w-23 h-23! touch-none select-none shadow-xl rounded-2xl flex items-center justify-center",
|
||||||
|
isSpeaking && "border-3! border-(--primary-foreground-alt)/75!",
|
||||||
|
isDragging ? "cursor-grabbing" : "cursor-grab",
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
backgroundColor: avatarBackgroundColor,
|
||||||
|
transform: `translate3d(${coords.x}px, ${coords.y}px, 0)`,
|
||||||
|
transition: isDragging
|
||||||
|
? "none"
|
||||||
|
: "transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)",
|
||||||
|
willChange: "transform",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Avatar className="size-14">
|
||||||
|
<AvatarImage src={lastSpeakingUser?.Avatar} />
|
||||||
|
<AvatarFallback className="text-lg">
|
||||||
|
{lastSpeakingUser?.Display.slice(0, 2).toUpperCase() ?? "..."}
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Popout({ participant }: { participant: Participant }) {
|
export function Popout({ participant }: { participant: Participant }) {
|
||||||
const screenSharePublication = getTrackPublicationBySource(
|
const screenSharePublication = getTrackPublicationBySource(
|
||||||
|
|
@ -455,7 +698,10 @@ export function Popout({ participant }: { participant: Participant }) {
|
||||||
export default function Wrapper() {
|
export default function Wrapper() {
|
||||||
const room = getRoom();
|
const room = getRoom();
|
||||||
const { pathname } = useLocation();
|
const { pathname } = useLocation();
|
||||||
|
const { openMobile } = useSidebar();
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const state = useCall((state) => state.state);
|
const state = useCall((state) => state.state);
|
||||||
|
const callId = useCall((state) => state.callId);
|
||||||
const watchedStreamParticipantIds = useCall(
|
const watchedStreamParticipantIds = useCall(
|
||||||
(state) => state.watchedStreamParticipantIds,
|
(state) => state.watchedStreamParticipantIds,
|
||||||
);
|
);
|
||||||
|
|
@ -466,6 +712,17 @@ export default function Wrapper() {
|
||||||
String(lastFocusedParticipantId),
|
String(lastFocusedParticipantId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (isMobile) {
|
||||||
|
return (
|
||||||
|
<MobileCallPill
|
||||||
|
active={
|
||||||
|
(!pathname.startsWith("/call") || openMobile) && state === "open"
|
||||||
|
}
|
||||||
|
callId={callId ?? ""}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
if (!participant || !lastFocusedParticipantId) {
|
if (!participant || !lastFocusedParticipantId) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,336 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import {
|
|
||||||
Button,
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogFooter,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
Label,
|
|
||||||
Select,
|
|
||||||
SelectContent,
|
|
||||||
SelectItem,
|
|
||||||
SelectTrigger,
|
|
||||||
SelectValue,
|
|
||||||
Switch,
|
|
||||||
} from "@tensamin/ui";
|
|
||||||
import {
|
|
||||||
type DesktopScreenShareAudioOutput,
|
|
||||||
type DesktopScreenShareCapabilities,
|
|
||||||
type DesktopScreenShareSource,
|
|
||||||
useDesktopMedia,
|
|
||||||
} from "@tensamin/shared/desktopMedia";
|
|
||||||
import { toast } from "@tensamin/shared/log";
|
|
||||||
import { AppWindow, Loader2, MonitorUp } from "lucide-react";
|
|
||||||
import type { ScreenShareCaptureOptions } from "livekit-client";
|
|
||||||
import { setScreenShareEnabled, startLinuxDesktopScreenShare } from "../store";
|
|
||||||
|
|
||||||
const NONE_AUDIO_OUTPUT = "__none__";
|
|
||||||
|
|
||||||
function buildScreenShareOptions(
|
|
||||||
source: DesktopScreenShareSource,
|
|
||||||
capabilities: DesktopScreenShareCapabilities,
|
|
||||||
selectedAudioOutputId: string,
|
|
||||||
shareAudio: boolean,
|
|
||||||
): ScreenShareCaptureOptions {
|
|
||||||
const wantsAudio = capabilities.showAudioOutputSelector
|
|
||||||
? selectedAudioOutputId !== NONE_AUDIO_OUTPUT
|
|
||||||
: capabilities.hasReliableSystemAudio && shareAudio;
|
|
||||||
|
|
||||||
return {
|
|
||||||
audio: wantsAudio
|
|
||||||
? {
|
|
||||||
autoGainControl: false,
|
|
||||||
echoCancellation: false,
|
|
||||||
noiseSuppression: false,
|
|
||||||
}
|
|
||||||
: false,
|
|
||||||
video: {
|
|
||||||
displaySurface: source.kind === "window" ? "window" : "monitor",
|
|
||||||
},
|
|
||||||
systemAudio: wantsAudio ? "include" : "exclude",
|
|
||||||
surfaceSwitching: "exclude",
|
|
||||||
selfBrowserSurface: "exclude",
|
|
||||||
contentHint: "detail",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ScreenShareDialog({
|
|
||||||
open,
|
|
||||||
onOpenChange,
|
|
||||||
isScreensharing,
|
|
||||||
portalContainer,
|
|
||||||
}: {
|
|
||||||
open: boolean;
|
|
||||||
onOpenChange: (open: boolean) => void;
|
|
||||||
isScreensharing: boolean;
|
|
||||||
portalContainer?: HTMLElement;
|
|
||||||
}) {
|
|
||||||
const {
|
|
||||||
getScreenShareCapabilities,
|
|
||||||
listScreenShareAudioOutputs,
|
|
||||||
listScreenShareSources,
|
|
||||||
} = useDesktopMedia();
|
|
||||||
|
|
||||||
const [loading, setLoading] = useState(false);
|
|
||||||
const [sources, setSources] = useState<DesktopScreenShareSource[]>([]);
|
|
||||||
const [audioOutputs, setAudioOutputs] = useState<
|
|
||||||
DesktopScreenShareAudioOutput[]
|
|
||||||
>([]);
|
|
||||||
const [capabilities, setCapabilities] =
|
|
||||||
useState<DesktopScreenShareCapabilities | null>(null);
|
|
||||||
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
|
|
||||||
const [selectedAudioOutputId, setSelectedAudioOutputId] =
|
|
||||||
useState<string>(NONE_AUDIO_OUTPUT);
|
|
||||||
const [shareAudio, setShareAudio] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!open) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let active = true;
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
setSelectedSourceId(null);
|
|
||||||
setSelectedAudioOutputId(NONE_AUDIO_OUTPUT);
|
|
||||||
setShareAudio(false);
|
|
||||||
|
|
||||||
Promise.all([listScreenShareSources(), getScreenShareCapabilities()])
|
|
||||||
.then(async ([nextSources, nextCapabilities]) => {
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSources(nextSources);
|
|
||||||
setCapabilities(nextCapabilities);
|
|
||||||
|
|
||||||
if (nextCapabilities.showAudioOutputSelector) {
|
|
||||||
const nextOutputs = await listScreenShareAudioOutputs();
|
|
||||||
|
|
||||||
if (!active) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setAudioOutputs(nextOutputs);
|
|
||||||
} else {
|
|
||||||
setAudioOutputs([]);
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.catch((error) => {
|
|
||||||
console.error("Failed to load desktop share sources", error);
|
|
||||||
toast("error", "Failed to load screen share sources.");
|
|
||||||
})
|
|
||||||
.finally(() => {
|
|
||||||
if (active) {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
active = false;
|
|
||||||
};
|
|
||||||
}, [
|
|
||||||
getScreenShareCapabilities,
|
|
||||||
listScreenShareAudioOutputs,
|
|
||||||
listScreenShareSources,
|
|
||||||
open,
|
|
||||||
]);
|
|
||||||
|
|
||||||
const selectedSource =
|
|
||||||
sources.find((source) => source.id === selectedSourceId) ?? null;
|
|
||||||
|
|
||||||
async function startSharing() {
|
|
||||||
if (!selectedSource || !capabilities) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
if (capabilities.runtime === "electron") {
|
|
||||||
await startLinuxDesktopScreenShare(selectedSource.id);
|
|
||||||
} else if (capabilities.platform === "linux") {
|
|
||||||
await startLinuxDesktopScreenShare(selectedSource.id);
|
|
||||||
} else {
|
|
||||||
await setScreenShareEnabled(
|
|
||||||
true,
|
|
||||||
buildScreenShareOptions(
|
|
||||||
selectedSource,
|
|
||||||
capabilities,
|
|
||||||
selectedAudioOutputId,
|
|
||||||
shareAudio,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to start screen share", error);
|
|
||||||
toast(
|
|
||||||
"error",
|
|
||||||
error instanceof Error
|
|
||||||
? error.message
|
|
||||||
: "Failed to start screen sharing.",
|
|
||||||
);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopSharing() {
|
|
||||||
setLoading(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
await setScreenShareEnabled(false);
|
|
||||||
onOpenChange(false);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Failed to stop screen share", error);
|
|
||||||
toast("error", "Failed to stop screen sharing.");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
|
||||||
<DialogContent
|
|
||||||
className="max-w-3xl gap-0 overflow-hidden p-0"
|
|
||||||
portalProps={{ container: portalContainer }}
|
|
||||||
>
|
|
||||||
<DialogHeader className="p-4 pb-3">
|
|
||||||
<DialogTitle>Share your screen</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
Choose a window or display you want to share.
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
|
|
||||||
<div className="flex max-h-[75vh] flex-col gap-4 overflow-y-auto px-4 pb-4">
|
|
||||||
<div className="grid gap-3 sm:grid-cols-2">
|
|
||||||
{sources.map((source) => {
|
|
||||||
const selected = source.id === selectedSourceId;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
key={source.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setSelectedSourceId(source.id)}
|
|
||||||
className={[
|
|
||||||
"flex min-h-28 flex-col justify-between rounded-xl border p-4 text-left transition-colors",
|
|
||||||
selected
|
|
||||||
? "border-primary bg-primary/5"
|
|
||||||
: "border-border bg-card hover:bg-muted/50",
|
|
||||||
].join(" ")}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 text-sm font-medium">
|
|
||||||
{source.kind === "window" ? (
|
|
||||||
<AppWindow className="size-4" />
|
|
||||||
) : (
|
|
||||||
<MonitorUp className="size-4" />
|
|
||||||
)}
|
|
||||||
{source.name}
|
|
||||||
</div>
|
|
||||||
{source.subtitle && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{source.subtitle}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!loading && sources.length === 0 && (
|
|
||||||
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
|
||||||
No windows or displays found.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{capabilities?.showAudioOutputSelector ? (
|
|
||||||
<div className="flex flex-col gap-2">
|
|
||||||
<Label>Share audio output</Label>
|
|
||||||
<Select
|
|
||||||
value={selectedAudioOutputId}
|
|
||||||
onValueChange={(value) =>
|
|
||||||
setSelectedAudioOutputId(value ?? NONE_AUDIO_OUTPUT)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-full">
|
|
||||||
<SelectValue>
|
|
||||||
{selectedAudioOutputId === NONE_AUDIO_OUTPUT
|
|
||||||
? "None"
|
|
||||||
: (audioOutputs.find(
|
|
||||||
(output) => output.id === selectedAudioOutputId,
|
|
||||||
)?.name ?? "None")}
|
|
||||||
</SelectValue>
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
<SelectItem value={NONE_AUDIO_OUTPUT}>None</SelectItem>
|
|
||||||
{audioOutputs.map((output) => (
|
|
||||||
<SelectItem key={output.id} value={output.id}>
|
|
||||||
{output.isDefault
|
|
||||||
? `${output.name} (Default)`
|
|
||||||
: output.name}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
</div>
|
|
||||||
) : capabilities?.showAudioSwitch ? (
|
|
||||||
<div className="flex flex-col gap-2 rounded-xl border p-4">
|
|
||||||
<div className="flex items-center justify-between gap-4">
|
|
||||||
<div className="space-y-1">
|
|
||||||
<Label htmlFor="share-system-audio">Share audio</Label>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
Share system audio alongside your screen when the runtime
|
|
||||||
can provide it.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Switch
|
|
||||||
id="share-system-audio"
|
|
||||||
checked={shareAudio}
|
|
||||||
disabled={!capabilities.hasReliableSystemAudio}
|
|
||||||
onCheckedChange={setShareAudio}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{!capabilities.hasReliableSystemAudio && (
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
System audio sharing is not available on this platform.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{loading && (
|
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<Loader2 className="size-4 animate-spin" />
|
|
||||||
Loading sources...
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DialogFooter className="m-0! p-2!">
|
|
||||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
{isScreensharing && (
|
|
||||||
<Button
|
|
||||||
variant="destructive"
|
|
||||||
disabled={loading}
|
|
||||||
onClick={stopSharing}
|
|
||||||
>
|
|
||||||
Stop sharing
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
disabled={loading || selectedSource == null}
|
|
||||||
onClick={startSharing}
|
|
||||||
>
|
|
||||||
{loading ? <Loader2 className="size-4 animate-spin" /> : null}
|
|
||||||
Start sharing
|
|
||||||
</Button>
|
|
||||||
</DialogFooter>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -9,8 +9,8 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import ScreenshareButton from "./buttons/screenshare";
|
import MediaShareButton from "./buttons/mediaShare";
|
||||||
import MuteButton from "./buttons/mute";
|
import MuteButton from "./buttons/mute";
|
||||||
import DeafButton from "./buttons/deaf";
|
import DeafButton from "./buttons/deaf";
|
||||||
import { Room, Track } from "livekit-client";
|
import { Room, Track } from "livekit-client";
|
||||||
|
|
@ -18,25 +18,36 @@ import { useEffect, useState } from "react";
|
||||||
import { AreaChart, Area } from "recharts";
|
import { AreaChart, Area } from "recharts";
|
||||||
import LeaveButton from "./buttons/leave";
|
import LeaveButton from "./buttons/leave";
|
||||||
|
|
||||||
export default function SidebarBox() {
|
async function getPing(room: Room): Promise<number | undefined> {
|
||||||
const state = useCall((store) => store.state);
|
const report = await room.localParticipant
|
||||||
const isMobile = useIsMobile();
|
.getTrackPublication(Track.Source.Microphone)
|
||||||
|
?.track?.getRTCStatsReport();
|
||||||
|
|
||||||
return state === "closed" ? null : (
|
if (!report) return;
|
||||||
<Card className="p-1.5 gap-2" hidden={isMobile}>
|
|
||||||
<CardHeader className="p-0! pb-2! border-b-2">
|
let bestRtt: number | undefined;
|
||||||
<ConnectionBar />
|
|
||||||
</CardHeader>
|
report.forEach((stat) => {
|
||||||
<CardContent className="p-0! flex flex-col gap-1">
|
if (
|
||||||
<div className="flex justify-start gap-1">
|
stat.type === "candidate-pair" &&
|
||||||
<MuteButton className="w-9 h-9" />
|
stat.state === "succeeded" &&
|
||||||
<DeafButton className="w-9 h-9" />
|
stat.currentRoundTripTime != null
|
||||||
<ScreenshareButton className="w-9 h-9" defaultPortal />
|
) {
|
||||||
<LeaveButton className="w-9 h-9" />
|
bestRtt = stat.currentRoundTripTime * 1000;
|
||||||
</div>
|
}
|
||||||
</CardContent>
|
|
||||||
</Card>
|
if (stat.type === "remote-inbound-rtp" && stat.roundTripTime != null) {
|
||||||
);
|
bestRtt = stat.roundTripTime * 1000;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (bestRtt == null || bestRtt <= 0) return;
|
||||||
|
|
||||||
|
const roundedRtt = Math.round(bestRtt);
|
||||||
|
|
||||||
|
if (roundedRtt <= 0) return;
|
||||||
|
|
||||||
|
return roundedRtt;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionBar() {
|
function ConnectionBar() {
|
||||||
|
|
@ -47,14 +58,18 @@ function ConnectionBar() {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={
|
render={({ ref, onClick }) => (
|
||||||
<Button
|
<Button
|
||||||
onClick={() => openCallPage(callId || "")}
|
ref={ref as React.Ref<HTMLButtonElement>}
|
||||||
|
onClick={(event) => {
|
||||||
|
onClick?.(event);
|
||||||
|
openCallPage(callId || "");
|
||||||
|
}}
|
||||||
size="lg"
|
size="lg"
|
||||||
variant={
|
variant={
|
||||||
state === "open" && isEncrypted ? "subtleDefault" : "destructive"
|
state === "open" && isEncrypted ? "subtleDefault" : "destructive"
|
||||||
}
|
}
|
||||||
className="flex justify-between items-center"
|
className="flex items-center justify-between px-2!"
|
||||||
>
|
>
|
||||||
{state === "encrypting" && "Encrypting..."}
|
{state === "encrypting" && "Encrypting..."}
|
||||||
{state === "connecting" && "Connecting..."}
|
{state === "connecting" && "Connecting..."}
|
||||||
|
|
@ -70,7 +85,7 @@ function ConnectionBar() {
|
||||||
<LockOpen color="var(--destructive)" />
|
<LockOpen color="var(--destructive)" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
<TooltipContent>Click to open call page</TooltipContent>
|
<TooltipContent>Click to open call page</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|
@ -171,34 +186,23 @@ export function TinyPingGraph() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getPing(room: Room): Promise<number | undefined> {
|
export default function SidebarBox() {
|
||||||
const report = await room.localParticipant
|
const state = useCall((store) => store.state);
|
||||||
.getTrackPublication(Track.Source.Microphone)
|
const isMobile = useIsMobile();
|
||||||
?.track?.getRTCStatsReport();
|
|
||||||
|
|
||||||
if (!report) return;
|
return state === "closed" ? null : (
|
||||||
|
<Card className="p-1.5 gap-2" hidden={isMobile}>
|
||||||
let bestRtt: number | undefined;
|
<CardHeader className="p-0! pb-2! border-b-2">
|
||||||
|
<ConnectionBar />
|
||||||
report.forEach((stat) => {
|
</CardHeader>
|
||||||
if (
|
<CardContent className="p-0! flex flex-col gap-1">
|
||||||
stat.type === "candidate-pair" &&
|
<div className="flex justify-center gap-1">
|
||||||
stat.state === "succeeded" &&
|
<MuteButton className="h-9! w-[24%]" />
|
||||||
stat.currentRoundTripTime != null
|
<DeafButton className="h-9! w-[24%]" />
|
||||||
) {
|
<MediaShareButton className="h-9! w-[24%]!" defaultPortal />
|
||||||
bestRtt = stat.currentRoundTripTime * 1000;
|
<LeaveButton className="h-9! w-[24%]" />
|
||||||
}
|
</div>
|
||||||
|
</CardContent>
|
||||||
if (stat.type === "remote-inbound-rtp" && stat.roundTripTime != null) {
|
</Card>
|
||||||
bestRtt = stat.roundTripTime * 1000;
|
);
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (bestRtt == null || bestRtt <= 0) return;
|
|
||||||
|
|
||||||
const roundedRtt = Math.round(bestRtt);
|
|
||||||
|
|
||||||
if (roundedRtt <= 0) return;
|
|
||||||
|
|
||||||
return roundedRtt;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
|
|
||||||
export default function TopBar() {
|
export default function TopBar() {
|
||||||
const { get } = useUser();
|
const { get } = useUser();
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { VideoTrack, useParticipantTracks } from "@livekit/components-react";
|
import { VideoTrack, useParticipantTracks } from "@livekit/components-react";
|
||||||
import { TrackPublication } from "livekit-client";
|
import { TrackPublication } from "livekit-client";
|
||||||
import { getRoom } from "../store";
|
import { getRoom } from "../store";
|
||||||
import { cn } from "@tensamin/ui";
|
import { cn } from "@methanium/ui";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function VideoViewer({
|
export default function VideoViewer({
|
||||||
|
|
|
||||||
84
packages/call/src/mediaShare/browser.ts
Normal file
84
packages/call/src/mediaShare/browser.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import type {
|
||||||
|
MediaShareAdapter,
|
||||||
|
MediaShareCapabilities,
|
||||||
|
MediaShareKind,
|
||||||
|
MediaShareRequest,
|
||||||
|
MediaShareSession,
|
||||||
|
MediaShareSource,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export async function listCameraSources(): Promise<MediaShareSource[]> {
|
||||||
|
const permissionStream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: true,
|
||||||
|
});
|
||||||
|
let devices: MediaDeviceInfo[];
|
||||||
|
try {
|
||||||
|
devices = await navigator.mediaDevices.enumerateDevices();
|
||||||
|
} finally {
|
||||||
|
permissionStream.getTracks().forEach((track) => track.stop());
|
||||||
|
}
|
||||||
|
let cameraIndex = 0;
|
||||||
|
|
||||||
|
return devices
|
||||||
|
.filter((device) => device.kind === "videoinput")
|
||||||
|
.map((device) => ({
|
||||||
|
id: device.deviceId,
|
||||||
|
kind: "camera" as const,
|
||||||
|
name: device.label || `Camera ${++cameraIndex}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startCamera(
|
||||||
|
sourceId?: string,
|
||||||
|
): Promise<MediaShareSession> {
|
||||||
|
const stream = await navigator.mediaDevices.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: sourceId ? { deviceId: { exact: sourceId } } : true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return streamSession(stream);
|
||||||
|
}
|
||||||
|
|
||||||
|
function streamSession(stream: MediaStream): MediaShareSession {
|
||||||
|
return {
|
||||||
|
tracks: stream.getTracks(),
|
||||||
|
stop: async () => {
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class BrowserMediaShareAdapter implements MediaShareAdapter {
|
||||||
|
async getCapabilities(): Promise<MediaShareCapabilities> {
|
||||||
|
return {
|
||||||
|
runtime: "browser",
|
||||||
|
screenPicker: "native",
|
||||||
|
canShareScreenAudio: true,
|
||||||
|
canSelectScreenAudioOutput: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSources(kind: MediaShareKind): Promise<MediaShareSource[]> {
|
||||||
|
return kind === "camera" ? listCameraSources() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(request: MediaShareRequest): Promise<MediaShareSession> {
|
||||||
|
if (request.kind === "camera") {
|
||||||
|
return startCamera(request.sourceId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const options: DisplayMediaStreamOptions & {
|
||||||
|
systemAudio: "include" | "exclude";
|
||||||
|
surfaceSwitching: "include" | "exclude";
|
||||||
|
} = {
|
||||||
|
audio: request.includeAudio ?? true,
|
||||||
|
video: true,
|
||||||
|
systemAudio: request.includeAudio === false ? "exclude" : "include",
|
||||||
|
surfaceSwitching: "include",
|
||||||
|
};
|
||||||
|
const stream = await navigator.mediaDevices.getDisplayMedia(options);
|
||||||
|
|
||||||
|
return streamSession(stream);
|
||||||
|
}
|
||||||
|
}
|
||||||
154
packages/call/src/mediaShare/controller.ts
Normal file
154
packages/call/src/mediaShare/controller.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
||||||
|
import { log } from "@tensamin/shared/log";
|
||||||
|
import { type LocalTrack, Room, Track } from "livekit-client";
|
||||||
|
import {
|
||||||
|
getMediaShareAdapter,
|
||||||
|
type MediaShareKind,
|
||||||
|
type MediaShareRequest,
|
||||||
|
type MediaShareSession,
|
||||||
|
} from ".";
|
||||||
|
|
||||||
|
export type LocalMediaShareSession = {
|
||||||
|
tracks: Array<LocalTrack | MediaStreamTrack>;
|
||||||
|
capture: MediaShareSession;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaShareStoreState = {
|
||||||
|
screenShareSession: LocalMediaShareSession | null;
|
||||||
|
cameraSession: LocalMediaShareSession | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type MediaShareStoreSetState = (
|
||||||
|
updater:
|
||||||
|
| Partial<MediaShareStoreState>
|
||||||
|
| ((state: MediaShareStoreState) => Partial<MediaShareStoreState>),
|
||||||
|
) => void;
|
||||||
|
|
||||||
|
type MediaShareControllerOptions = {
|
||||||
|
room: Room;
|
||||||
|
getState: () => MediaShareStoreState;
|
||||||
|
setState: MediaShareStoreSetState;
|
||||||
|
getLocalParticipantId: () => number | null;
|
||||||
|
startWatching: (participantId: number) => void;
|
||||||
|
stopWatching: (participantId: number) => void;
|
||||||
|
syncParticipantState: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function createMediaShareController({
|
||||||
|
room,
|
||||||
|
getState,
|
||||||
|
setState,
|
||||||
|
getLocalParticipantId,
|
||||||
|
startWatching,
|
||||||
|
stopWatching,
|
||||||
|
syncParticipantState,
|
||||||
|
}: MediaShareControllerOptions) {
|
||||||
|
function getSession(kind: MediaShareKind) {
|
||||||
|
return kind === "screen"
|
||||||
|
? getState().screenShareSession
|
||||||
|
: getState().cameraSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSession(
|
||||||
|
kind: MediaShareKind,
|
||||||
|
session: LocalMediaShareSession | null,
|
||||||
|
) {
|
||||||
|
setState(
|
||||||
|
kind === "screen"
|
||||||
|
? { screenShareSession: session }
|
||||||
|
: { cameraSession: session },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearPublishedShare(kind: MediaShareKind) {
|
||||||
|
const session = getSession(kind);
|
||||||
|
if (!session) return;
|
||||||
|
|
||||||
|
setSession(kind, null);
|
||||||
|
await Promise.all(
|
||||||
|
session.tracks.map((track) =>
|
||||||
|
room.localParticipant.unpublishTrack(track, true).catch((error) => {
|
||||||
|
log(1, "call", "red", `Failed to unpublish ${kind} track`, error);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await session.capture.stop().catch((error) => {
|
||||||
|
log(1, "call", "red", `Failed to stop ${kind} capture`, error);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (kind === "screen") {
|
||||||
|
const localParticipantId = getLocalParticipantId();
|
||||||
|
if (localParticipantId != null) stopWatching(localParticipantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function publishShare(
|
||||||
|
kind: MediaShareKind,
|
||||||
|
capture: MediaShareSession,
|
||||||
|
) {
|
||||||
|
if (capture.tracks.length === 0) {
|
||||||
|
await capture.stop();
|
||||||
|
throw new Error(`No ${kind} tracks were created.`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const published: MediaStreamTrack[] = [];
|
||||||
|
try {
|
||||||
|
for (const track of capture.tracks) {
|
||||||
|
await room.localParticipant.publishTrack(track, {
|
||||||
|
source:
|
||||||
|
track.kind === Track.Kind.Audio
|
||||||
|
? Track.Source.ScreenShareAudio
|
||||||
|
: kind === "screen"
|
||||||
|
? Track.Source.ScreenShare
|
||||||
|
: Track.Source.Camera,
|
||||||
|
});
|
||||||
|
published.push(track);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
await Promise.all(
|
||||||
|
published.map((track) =>
|
||||||
|
room.localParticipant.unpublishTrack(track, true),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await capture.stop();
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const track of capture.tracks) {
|
||||||
|
track.addEventListener(
|
||||||
|
"ended",
|
||||||
|
() => {
|
||||||
|
void stop(kind);
|
||||||
|
},
|
||||||
|
{ once: true },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
setSession(kind, { tracks: capture.tracks, capture });
|
||||||
|
syncParticipantState();
|
||||||
|
|
||||||
|
if (kind === "screen") {
|
||||||
|
const localParticipantId = getLocalParticipantId();
|
||||||
|
if (localParticipantId != null) startWatching(localParticipantId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function start(request: MediaShareRequest) {
|
||||||
|
await clearPublishedShare(request.kind);
|
||||||
|
const capture = await getMediaShareAdapter().start(request);
|
||||||
|
await publishShare(request.kind, capture);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function stop(kind: MediaShareKind) {
|
||||||
|
await clearPublishedShare(kind);
|
||||||
|
syncParticipantState();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function clearAll() {
|
||||||
|
await Promise.all([
|
||||||
|
clearPublishedShare("screen"),
|
||||||
|
clearPublishedShare("camera"),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return { clearAll, start, stop };
|
||||||
|
}
|
||||||
54
packages/call/src/mediaShare/electron.ts
Normal file
54
packages/call/src/mediaShare/electron.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
import type {} from "@tensamin/shared/desktopMedia";
|
||||||
|
import { BrowserMediaShareAdapter, listCameraSources } from "./browser";
|
||||||
|
import type {
|
||||||
|
MediaShareCapabilities,
|
||||||
|
MediaShareKind,
|
||||||
|
MediaShareRequest,
|
||||||
|
MediaShareSession,
|
||||||
|
MediaShareSource,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
export class ElectronMediaShareAdapter extends BrowserMediaShareAdapter {
|
||||||
|
override async getCapabilities(): Promise<MediaShareCapabilities> {
|
||||||
|
const capabilities =
|
||||||
|
await window.tensaminDesktop?.media?.getScreenShareCapabilities?.();
|
||||||
|
|
||||||
|
return {
|
||||||
|
runtime: "electron",
|
||||||
|
screenPicker: "sources",
|
||||||
|
canShareScreenAudio: capabilities?.hasReliableSystemAudio ?? false,
|
||||||
|
canSelectScreenAudioOutput:
|
||||||
|
capabilities?.showAudioOutputSelector ?? false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
override async listSources(
|
||||||
|
kind: MediaShareKind,
|
||||||
|
): Promise<MediaShareSource[]> {
|
||||||
|
if (kind === "camera") {
|
||||||
|
return listCameraSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
(await window.tensaminDesktop?.media?.listScreenShareSources?.()) ?? []
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
override async start(request: MediaShareRequest): Promise<MediaShareSession> {
|
||||||
|
if (request.kind === "camera") {
|
||||||
|
return super.start(request);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!request.sourceId) {
|
||||||
|
throw new Error("Choose a screen or window to share.");
|
||||||
|
}
|
||||||
|
|
||||||
|
const select = window.tensaminDesktop?.media?.selectScreenShareSource;
|
||||||
|
if (!select) {
|
||||||
|
throw new Error("Electron screen capture is unavailable.");
|
||||||
|
}
|
||||||
|
|
||||||
|
await select(request.sourceId);
|
||||||
|
return super.start({ ...request, sourceId: undefined });
|
||||||
|
}
|
||||||
|
}
|
||||||
27
packages/call/src/mediaShare/index.ts
Normal file
27
packages/call/src/mediaShare/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
import { BrowserMediaShareAdapter } from "./browser";
|
||||||
|
import { ElectronMediaShareAdapter } from "./electron";
|
||||||
|
import { TauriMediaShareAdapter } from "./tauri";
|
||||||
|
import type { MediaShareAdapter } from "./types";
|
||||||
|
|
||||||
|
let adapter: MediaShareAdapter | null = null;
|
||||||
|
|
||||||
|
export function getMediaShareAdapter(): MediaShareAdapter {
|
||||||
|
if (!adapter) {
|
||||||
|
adapter = window.tensaminMobileMedia
|
||||||
|
? new TauriMediaShareAdapter()
|
||||||
|
: window.tensaminDesktop?.media
|
||||||
|
? new ElectronMediaShareAdapter()
|
||||||
|
: new BrowserMediaShareAdapter();
|
||||||
|
}
|
||||||
|
|
||||||
|
return adapter;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type {
|
||||||
|
MediaShareAdapter,
|
||||||
|
MediaShareCapabilities,
|
||||||
|
MediaShareKind,
|
||||||
|
MediaShareRequest,
|
||||||
|
MediaShareSession,
|
||||||
|
MediaShareSource,
|
||||||
|
} from "./types";
|
||||||
270
packages/call/src/mediaShare/tauri.ts
Normal file
270
packages/call/src/mediaShare/tauri.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
||||||
|
import { listCameraSources, startCamera } from "./browser";
|
||||||
|
import type {
|
||||||
|
MediaShareAdapter,
|
||||||
|
MediaShareCapabilities,
|
||||||
|
MediaShareKind,
|
||||||
|
MediaShareRequest,
|
||||||
|
MediaShareSession,
|
||||||
|
MediaShareSource,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
type MobileMediaApi = {
|
||||||
|
startScreenShare: (includeAudio: boolean) => void;
|
||||||
|
stopScreenShare: () => void;
|
||||||
|
requestCameraPermission: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
declare global {
|
||||||
|
interface Window {
|
||||||
|
tensaminMobileMedia?: MobileMediaApi;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type FrameDetail = {
|
||||||
|
data: string;
|
||||||
|
mimeType: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AudioDetail = {
|
||||||
|
data: string;
|
||||||
|
sampleRate: number;
|
||||||
|
channelCount: number;
|
||||||
|
encoding: "pcm16le";
|
||||||
|
};
|
||||||
|
|
||||||
|
function eventDetail<T>(event: Event): T {
|
||||||
|
return (event as CustomEvent<T>).detail;
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeBase64(value: string): Uint8Array {
|
||||||
|
const decoded = atob(value);
|
||||||
|
const bytes = new Uint8Array(decoded.length);
|
||||||
|
for (let index = 0; index < decoded.length; index += 1) {
|
||||||
|
bytes[index] = decoded.charCodeAt(index);
|
||||||
|
}
|
||||||
|
return bytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function requestCameraPermission() {
|
||||||
|
const bridge = window.tensaminMobileMedia;
|
||||||
|
if (!bridge) throw new Error("Tauri mobile media bridge is unavailable.");
|
||||||
|
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error("Timed out waiting for camera permission."));
|
||||||
|
}, 30_000);
|
||||||
|
const onPermission = (event: Event) => {
|
||||||
|
cleanup();
|
||||||
|
const permission = eventDetail<{ camera: boolean }>(event);
|
||||||
|
if (permission.camera) {
|
||||||
|
resolve();
|
||||||
|
} else {
|
||||||
|
reject(new Error("Camera permission was denied."));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const cleanup = () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
window.removeEventListener(
|
||||||
|
"tensamin-mobile-camera-permission",
|
||||||
|
onPermission,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener("tensamin-mobile-camera-permission", onPermission);
|
||||||
|
bridge.requestCameraPermission();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function startMobileScreen(
|
||||||
|
includeAudio: boolean,
|
||||||
|
): Promise<MediaShareSession> {
|
||||||
|
const bridge = window.tensaminMobileMedia;
|
||||||
|
if (!bridge) throw new Error("Tauri mobile media bridge is unavailable.");
|
||||||
|
|
||||||
|
return new Promise<MediaShareSession>((resolve, reject) => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const context = canvas.getContext("2d");
|
||||||
|
if (!context) {
|
||||||
|
reject(new Error("Unable to create the mobile capture canvas."));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const stream = canvas.captureStream(15);
|
||||||
|
let audioContext: AudioContext | null = null;
|
||||||
|
let audioNode: ScriptProcessorNode | null = null;
|
||||||
|
let audioDestination: MediaStreamAudioDestinationNode | null = null;
|
||||||
|
const audioQueue: Float32Array[] = [];
|
||||||
|
let audioQueueOffset = 0;
|
||||||
|
let queuedAudioSamples = 0;
|
||||||
|
let started = false;
|
||||||
|
let resolved = false;
|
||||||
|
let firstFrame = false;
|
||||||
|
let lastError: Error | null = null;
|
||||||
|
let errorTimer = 0;
|
||||||
|
|
||||||
|
const timeout = window.setTimeout(() => {
|
||||||
|
cleanup();
|
||||||
|
bridge.stopScreenShare();
|
||||||
|
reject(
|
||||||
|
lastError ?? new Error("Timed out starting mobile screen sharing."),
|
||||||
|
);
|
||||||
|
}, 60_000);
|
||||||
|
|
||||||
|
const complete = () => {
|
||||||
|
if (!started || !firstFrame || resolved) return;
|
||||||
|
resolved = true;
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
resolve({
|
||||||
|
tracks: stream.getTracks(),
|
||||||
|
stop: async () => {
|
||||||
|
bridge.stopScreenShare();
|
||||||
|
cleanup();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStarted = (event: Event) => {
|
||||||
|
started = true;
|
||||||
|
const detail = eventDetail<{ includeAudio: boolean }>(event);
|
||||||
|
if (detail.includeAudio) {
|
||||||
|
audioContext = new AudioContext({ sampleRate: 48_000 });
|
||||||
|
audioNode = audioContext.createScriptProcessor(2048, 0, 1);
|
||||||
|
audioDestination = audioContext.createMediaStreamDestination();
|
||||||
|
audioNode.onaudioprocess = ({ outputBuffer }) => {
|
||||||
|
const output = outputBuffer.getChannelData(0);
|
||||||
|
output.fill(0);
|
||||||
|
let outputOffset = 0;
|
||||||
|
while (outputOffset < output.length && audioQueue.length > 0) {
|
||||||
|
const chunk = audioQueue[0];
|
||||||
|
const available = chunk.length - audioQueueOffset;
|
||||||
|
const count = Math.min(available, output.length - outputOffset);
|
||||||
|
output.set(
|
||||||
|
chunk.subarray(audioQueueOffset, audioQueueOffset + count),
|
||||||
|
outputOffset,
|
||||||
|
);
|
||||||
|
outputOffset += count;
|
||||||
|
audioQueueOffset += count;
|
||||||
|
queuedAudioSamples -= count;
|
||||||
|
if (audioQueueOffset === chunk.length) {
|
||||||
|
audioQueue.shift();
|
||||||
|
audioQueueOffset = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
audioNode.connect(audioDestination);
|
||||||
|
void audioContext.resume();
|
||||||
|
for (const track of audioDestination.stream.getAudioTracks()) {
|
||||||
|
stream.addTrack(track);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
complete();
|
||||||
|
};
|
||||||
|
|
||||||
|
const onFrame = (event: Event) => {
|
||||||
|
const detail = eventDetail<FrameDetail>(event);
|
||||||
|
const image = new Image();
|
||||||
|
image.onload = () => {
|
||||||
|
if (canvas.width !== detail.width || canvas.height !== detail.height) {
|
||||||
|
canvas.width = detail.width;
|
||||||
|
canvas.height = detail.height;
|
||||||
|
}
|
||||||
|
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||||
|
firstFrame = true;
|
||||||
|
complete();
|
||||||
|
};
|
||||||
|
image.src = `data:${detail.mimeType};base64,${detail.data}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const onAudio = (event: Event) => {
|
||||||
|
const bytes = decodeBase64(eventDetail<AudioDetail>(event).data);
|
||||||
|
const samples = new Int16Array(
|
||||||
|
bytes.buffer,
|
||||||
|
bytes.byteOffset,
|
||||||
|
Math.floor(bytes.byteLength / 2),
|
||||||
|
);
|
||||||
|
const chunk = new Float32Array(samples.length);
|
||||||
|
for (let index = 0; index < samples.length; index += 1) {
|
||||||
|
chunk[index] = samples[index] / 32768;
|
||||||
|
}
|
||||||
|
audioQueue.push(chunk);
|
||||||
|
queuedAudioSamples += chunk.length;
|
||||||
|
const maximumQueuedSamples = 48_000 * 2;
|
||||||
|
while (queuedAudioSamples > maximumQueuedSamples && audioQueue.length) {
|
||||||
|
const dropped = audioQueue.shift();
|
||||||
|
if (!dropped) break;
|
||||||
|
queuedAudioSamples -= dropped.length - audioQueueOffset;
|
||||||
|
audioQueueOffset = 0;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const onStopped = () => {
|
||||||
|
cleanup();
|
||||||
|
if (!resolved) reject(new Error("Mobile screen sharing was stopped."));
|
||||||
|
};
|
||||||
|
|
||||||
|
const onError = (event: Event) => {
|
||||||
|
lastError = new Error(eventDetail<{ message: string }>(event).message);
|
||||||
|
window.clearTimeout(errorTimer);
|
||||||
|
errorTimer = window.setTimeout(() => {
|
||||||
|
if (!started && !resolved) {
|
||||||
|
cleanup();
|
||||||
|
reject(lastError ?? new Error("Mobile screen sharing failed."));
|
||||||
|
}
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const listeners: Array<[string, EventListener]> = [
|
||||||
|
["tensamin-mobile-screen-started", onStarted],
|
||||||
|
["tensamin-mobile-screen-frame", onFrame],
|
||||||
|
["tensamin-mobile-screen-audio", onAudio],
|
||||||
|
["tensamin-mobile-screen-stopped", onStopped],
|
||||||
|
["tensamin-mobile-screen-error", onError],
|
||||||
|
];
|
||||||
|
const cleanup = () => {
|
||||||
|
window.clearTimeout(timeout);
|
||||||
|
window.clearTimeout(errorTimer);
|
||||||
|
listeners.forEach(([name, listener]) =>
|
||||||
|
window.removeEventListener(name, listener),
|
||||||
|
);
|
||||||
|
stream.getTracks().forEach((track) => track.stop());
|
||||||
|
audioNode?.disconnect();
|
||||||
|
audioNode = null;
|
||||||
|
void audioContext?.close();
|
||||||
|
audioContext = null;
|
||||||
|
};
|
||||||
|
|
||||||
|
listeners.forEach(([name, listener]) =>
|
||||||
|
window.addEventListener(name, listener),
|
||||||
|
);
|
||||||
|
bridge.startScreenShare(includeAudio);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export class TauriMediaShareAdapter implements MediaShareAdapter {
|
||||||
|
async getCapabilities(): Promise<MediaShareCapabilities> {
|
||||||
|
return {
|
||||||
|
runtime: "tauri",
|
||||||
|
screenPicker: "system",
|
||||||
|
canShareScreenAudio: true,
|
||||||
|
canSelectScreenAudioOutput: false,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async listSources(kind: MediaShareKind): Promise<MediaShareSource[]> {
|
||||||
|
if (kind !== "camera") return [];
|
||||||
|
await requestCameraPermission();
|
||||||
|
return listCameraSources();
|
||||||
|
}
|
||||||
|
|
||||||
|
async start(request: MediaShareRequest): Promise<MediaShareSession> {
|
||||||
|
if (request.kind === "screen") {
|
||||||
|
return startMobileScreen(request.includeAudio ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
await requestCameraPermission();
|
||||||
|
return startCamera(request.sourceId);
|
||||||
|
}
|
||||||
|
}
|
||||||
33
packages/call/src/mediaShare/types.ts
Normal file
33
packages/call/src/mediaShare/types.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
||||||
|
export type MediaShareKind = "screen" | "camera";
|
||||||
|
|
||||||
|
export type MediaShareSource = {
|
||||||
|
id: string;
|
||||||
|
kind: "screen" | "window" | "camera";
|
||||||
|
name: string;
|
||||||
|
subtitle?: string | null;
|
||||||
|
thumbnail?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaShareCapabilities = {
|
||||||
|
runtime: "browser" | "electron" | "tauri";
|
||||||
|
screenPicker: "native" | "sources" | "system";
|
||||||
|
canShareScreenAudio: boolean;
|
||||||
|
canSelectScreenAudioOutput: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaShareRequest = {
|
||||||
|
kind: MediaShareKind;
|
||||||
|
sourceId?: string;
|
||||||
|
includeAudio?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MediaShareSession = {
|
||||||
|
tracks: MediaStreamTrack[];
|
||||||
|
stop: () => Promise<void>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface MediaShareAdapter {
|
||||||
|
getCapabilities(): Promise<MediaShareCapabilities>;
|
||||||
|
listSources(kind: MediaShareKind): Promise<MediaShareSource[]>;
|
||||||
|
start(request: MediaShareRequest): Promise<MediaShareSession>;
|
||||||
|
}
|
||||||
|
|
@ -31,8 +31,13 @@ function copyDocumentStyles(targetDocument: Document) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDocumentClasses(targetDocument: Document) {
|
function syncDocumentAttributes(targetDocument: Document) {
|
||||||
targetDocument.documentElement.className = document.documentElement.className;
|
for (const attribute of document.documentElement.attributes) {
|
||||||
|
targetDocument.documentElement.setAttribute(
|
||||||
|
attribute.name,
|
||||||
|
attribute.value,
|
||||||
|
);
|
||||||
|
}
|
||||||
targetDocument.body.className = document.body.className;
|
targetDocument.body.className = document.body.className;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -58,12 +63,12 @@ function PopoutScreen() {
|
||||||
popoutWindow.document.title = document.title;
|
popoutWindow.document.title = document.title;
|
||||||
popoutWindow.document.body.innerHTML = "";
|
popoutWindow.document.body.innerHTML = "";
|
||||||
popoutWindow.document.body.style.margin = "0";
|
popoutWindow.document.body.style.margin = "0";
|
||||||
|
copyDocumentStyles(popoutWindow.document);
|
||||||
|
syncDocumentAttributes(popoutWindow.document);
|
||||||
|
|
||||||
popoutWindow.document.documentElement.style.height = "100%";
|
popoutWindow.document.documentElement.style.height = "100%";
|
||||||
popoutWindow.document.body.style.height = "100%";
|
popoutWindow.document.body.style.height = "100%";
|
||||||
|
|
||||||
copyDocumentStyles(popoutWindow.document);
|
|
||||||
syncDocumentClasses(popoutWindow.document);
|
|
||||||
|
|
||||||
const containerElement = popoutWindow.document.createElement("div");
|
const containerElement = popoutWindow.document.createElement("div");
|
||||||
containerElement.style.width = "100%";
|
containerElement.style.width = "100%";
|
||||||
containerElement.style.height = "100%";
|
containerElement.style.height = "100%";
|
||||||
|
|
|
||||||
|
|
@ -1,177 +0,0 @@
|
||||||
import { log } from "@tensamin/shared/log";
|
|
||||||
import type {} from "@tensamin/shared/desktopMedia";
|
|
||||||
import {
|
|
||||||
type LocalTrack,
|
|
||||||
Room,
|
|
||||||
type ScreenShareCaptureOptions,
|
|
||||||
Track,
|
|
||||||
} from "livekit-client";
|
|
||||||
|
|
||||||
export type ScreenShareSession = {
|
|
||||||
tracks: Array<LocalTrack | MediaStreamTrack>;
|
|
||||||
cleanup?: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenShareStoreState = {
|
|
||||||
screenShareSession: ScreenShareSession | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type ScreenShareStoreSetState = (
|
|
||||||
updater:
|
|
||||||
| Partial<ScreenShareStoreState>
|
|
||||||
| ((state: ScreenShareStoreState) => Partial<ScreenShareStoreState>),
|
|
||||||
) => void;
|
|
||||||
|
|
||||||
type ScreenShareControllerOptions = {
|
|
||||||
room: Room;
|
|
||||||
getState: () => ScreenShareStoreState;
|
|
||||||
setState: ScreenShareStoreSetState;
|
|
||||||
getLocalParticipantId: () => number | null;
|
|
||||||
startWatching: (participantId: number, options?: { focus?: boolean }) => void;
|
|
||||||
stopWatching: (participantId: number) => void;
|
|
||||||
syncParticipantState: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export function createScreenShareController({
|
|
||||||
room,
|
|
||||||
getState,
|
|
||||||
setState,
|
|
||||||
getLocalParticipantId,
|
|
||||||
startWatching,
|
|
||||||
stopWatching,
|
|
||||||
syncParticipantState,
|
|
||||||
}: ScreenShareControllerOptions) {
|
|
||||||
async function clearPublishedScreenShare() {
|
|
||||||
const screenShareSession = getState().screenShareSession;
|
|
||||||
|
|
||||||
if (!screenShareSession) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
screenShareSession.tracks.map((track) =>
|
|
||||||
room.localParticipant.unpublishTrack(track, true).catch((error) => {
|
|
||||||
log(
|
|
||||||
1,
|
|
||||||
"call",
|
|
||||||
"red",
|
|
||||||
"Failed to unpublish screen share track",
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
screenShareSession.cleanup?.();
|
|
||||||
|
|
||||||
const localParticipantId = getLocalParticipantId();
|
|
||||||
|
|
||||||
if (localParticipantId != null) {
|
|
||||||
stopWatching(localParticipantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({ screenShareSession: null });
|
|
||||||
}
|
|
||||||
|
|
||||||
async function publishScreenShareTracks(
|
|
||||||
tracks: Array<LocalTrack | MediaStreamTrack>,
|
|
||||||
cleanup?: () => void,
|
|
||||||
) {
|
|
||||||
if (tracks.length === 0) {
|
|
||||||
throw new Error("No screen share tracks were created.");
|
|
||||||
}
|
|
||||||
|
|
||||||
await Promise.all(
|
|
||||||
tracks.map((track) =>
|
|
||||||
room.localParticipant.publishTrack(track, {
|
|
||||||
source:
|
|
||||||
track.kind === Track.Kind.Video
|
|
||||||
? Track.Source.ScreenShare
|
|
||||||
: Track.Source.ScreenShareAudio,
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
for (const track of tracks) {
|
|
||||||
const mediaStreamTrack =
|
|
||||||
track instanceof MediaStreamTrack ? track : track.mediaStreamTrack;
|
|
||||||
|
|
||||||
mediaStreamTrack.addEventListener(
|
|
||||||
"ended",
|
|
||||||
() => {
|
|
||||||
void stopScreenShare();
|
|
||||||
},
|
|
||||||
{ once: true },
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
setState({ screenShareSession: { tracks, cleanup } });
|
|
||||||
syncParticipantState();
|
|
||||||
|
|
||||||
const localParticipantId = getLocalParticipantId();
|
|
||||||
|
|
||||||
if (localParticipantId != null) {
|
|
||||||
startWatching(localParticipantId, { focus: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startScreenShare(options?: ScreenShareCaptureOptions) {
|
|
||||||
await clearPublishedScreenShare();
|
|
||||||
|
|
||||||
const tracks = await room.localParticipant.createScreenTracks(options);
|
|
||||||
|
|
||||||
await publishScreenShareTracks(tracks, () => {
|
|
||||||
tracks.forEach((track) => track.stop());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function startLinuxDesktopScreenShare(sourceId: string) {
|
|
||||||
await clearPublishedScreenShare();
|
|
||||||
|
|
||||||
if (window.tensaminDesktop?.media?.selectScreenShareSource) {
|
|
||||||
await window.tensaminDesktop.media.selectScreenShareSource(sourceId);
|
|
||||||
const tracks = await room.localParticipant.createScreenTracks({
|
|
||||||
audio: false,
|
|
||||||
video: true,
|
|
||||||
systemAudio: "exclude",
|
|
||||||
surfaceSwitching: "exclude",
|
|
||||||
selfBrowserSurface: "exclude",
|
|
||||||
contentHint: "detail",
|
|
||||||
});
|
|
||||||
|
|
||||||
await publishScreenShareTracks(tracks, () => {
|
|
||||||
tracks.forEach((track) => track.stop());
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
throw new Error(
|
|
||||||
`Electron desktop media bridge is unavailable. Cannot capture ${sourceId}.`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function stopScreenShare() {
|
|
||||||
await clearPublishedScreenShare();
|
|
||||||
syncParticipantState();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function setScreenShareEnabled(
|
|
||||||
enabled: boolean,
|
|
||||||
options?: ScreenShareCaptureOptions,
|
|
||||||
) {
|
|
||||||
if (enabled) {
|
|
||||||
await startScreenShare(options);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
await stopScreenShare();
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
clearPublishedScreenShare,
|
|
||||||
startLinuxDesktopScreenShare,
|
|
||||||
startScreenShare,
|
|
||||||
stopScreenShare,
|
|
||||||
setScreenShareEnabled,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
@ -2,11 +2,13 @@ import { create } from "zustand";
|
||||||
|
|
||||||
type SpeakingState = {
|
type SpeakingState = {
|
||||||
speakingParticipantIds: Set<number>;
|
speakingParticipantIds: Set<number>;
|
||||||
|
lastSpeakingParticipantId: number | null;
|
||||||
micGated: boolean;
|
micGated: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const useSpeakingState = create<SpeakingState>(() => ({
|
const useSpeakingState = create<SpeakingState>(() => ({
|
||||||
speakingParticipantIds: new Set(),
|
speakingParticipantIds: new Set(),
|
||||||
|
lastSpeakingParticipantId: null,
|
||||||
micGated: false,
|
micGated: false,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -20,10 +22,19 @@ export function clearSpeakingParticipants() {
|
||||||
|
|
||||||
export function removeSpeakingParticipant(participantId: number) {
|
export function removeSpeakingParticipant(participantId: number) {
|
||||||
useSpeakingState.setState((state) => {
|
useSpeakingState.setState((state) => {
|
||||||
if (!state.speakingParticipantIds.has(participantId)) return state;
|
const wasSpeaking = state.speakingParticipantIds.has(participantId);
|
||||||
|
const wasLastSpeaking = state.lastSpeakingParticipantId === participantId;
|
||||||
|
|
||||||
|
if (!wasSpeaking && !wasLastSpeaking) return state;
|
||||||
|
|
||||||
const next = new Set(state.speakingParticipantIds);
|
const next = new Set(state.speakingParticipantIds);
|
||||||
next.delete(participantId);
|
next.delete(participantId);
|
||||||
return { speakingParticipantIds: next };
|
return {
|
||||||
|
speakingParticipantIds: next,
|
||||||
|
lastSpeakingParticipantId: wasLastSpeaking
|
||||||
|
? null
|
||||||
|
: state.lastSpeakingParticipantId,
|
||||||
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -31,11 +42,13 @@ export function updateSpeakingParticipants(changed: Map<number, boolean>) {
|
||||||
useSpeakingState.setState((state) => {
|
useSpeakingState.setState((state) => {
|
||||||
let hasDiff = false;
|
let hasDiff = false;
|
||||||
const next = new Set(state.speakingParticipantIds);
|
const next = new Set(state.speakingParticipantIds);
|
||||||
|
let lastSpeakingParticipantId = state.lastSpeakingParticipantId;
|
||||||
|
|
||||||
for (const [id, speaking] of changed) {
|
for (const [id, speaking] of changed) {
|
||||||
if (speaking) {
|
if (speaking) {
|
||||||
if (!next.has(id)) {
|
if (!next.has(id)) {
|
||||||
next.add(id);
|
next.add(id);
|
||||||
|
lastSpeakingParticipantId = id;
|
||||||
hasDiff = true;
|
hasDiff = true;
|
||||||
}
|
}
|
||||||
} else if (next.has(id)) {
|
} else if (next.has(id)) {
|
||||||
|
|
@ -44,10 +57,16 @@ export function updateSpeakingParticipants(changed: Map<number, boolean>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasDiff ? { speakingParticipantIds: next } : state;
|
return hasDiff
|
||||||
|
? { speakingParticipantIds: next, lastSpeakingParticipantId }
|
||||||
|
: state;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useLastSpeakingParticipantId(): number | null {
|
||||||
|
return useSpeakingState((state) => state.lastSpeakingParticipantId);
|
||||||
|
}
|
||||||
|
|
||||||
export function useIsSpeaking(participantId: number): boolean {
|
export function useIsSpeaking(participantId: number): boolean {
|
||||||
return useSpeakingState((state) =>
|
return useSpeakingState((state) =>
|
||||||
state.speakingParticipantIds.has(participantId),
|
state.speakingParticipantIds.has(participantId),
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { mtp } from "@tensamin/shared/data";
|
import { mtp } from "@tensamin/shared/data";
|
||||||
|
import { playSound, stopSound } from "@tensamin/shared/sounds";
|
||||||
import { bytesToBase64 } from "mtp";
|
import { bytesToBase64 } from "mtp";
|
||||||
import {
|
import {
|
||||||
deriveCallSecretId,
|
deriveCallSecretId,
|
||||||
|
|
@ -25,16 +26,16 @@ import {
|
||||||
Room,
|
Room,
|
||||||
RoomEvent,
|
RoomEvent,
|
||||||
type RemoteTrack,
|
type RemoteTrack,
|
||||||
type ScreenShareCaptureOptions,
|
|
||||||
Track,
|
Track,
|
||||||
setLogExtension,
|
setLogExtension,
|
||||||
getLogger,
|
getLogger,
|
||||||
} from "livekit-client";
|
} from "livekit-client";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
import {
|
import {
|
||||||
createScreenShareController,
|
createMediaShareController,
|
||||||
type ScreenShareSession,
|
type LocalMediaShareSession,
|
||||||
} from "./screenshare";
|
} from "./mediaShare/controller";
|
||||||
|
import type { MediaShareRequest } from "./mediaShare";
|
||||||
import {
|
import {
|
||||||
getSpeakingDetector,
|
getSpeakingDetector,
|
||||||
disposeSpeakingDetector,
|
disposeSpeakingDetector,
|
||||||
|
|
@ -100,8 +101,11 @@ type CallStore = {
|
||||||
currentCallData: CurrentCallData;
|
currentCallData: CurrentCallData;
|
||||||
deaf: boolean;
|
deaf: boolean;
|
||||||
micEnabled: boolean;
|
micEnabled: boolean;
|
||||||
|
cameraEnabled: boolean;
|
||||||
screenShareEnabled: boolean;
|
screenShareEnabled: boolean;
|
||||||
screenShareSession: ScreenShareSession | null;
|
screenShareSession: LocalMediaShareSession | null;
|
||||||
|
cameraSession: LocalMediaShareSession | null;
|
||||||
|
disabledCameraParticipantIds: number[];
|
||||||
focusedParticipantId: number | null;
|
focusedParticipantId: number | null;
|
||||||
focusedParticipantType: "user" | "stream" | null;
|
focusedParticipantType: "user" | "stream" | null;
|
||||||
usersInFocusedViewHidden: boolean;
|
usersInFocusedViewHidden: boolean;
|
||||||
|
|
@ -154,6 +158,8 @@ export function getRoom(): Room {
|
||||||
}
|
}
|
||||||
|
|
||||||
const remoteAudioElements = new Map<string, HTMLMediaElement>();
|
const remoteAudioElements = new Map<string, HTMLMediaElement>();
|
||||||
|
let callJingle: HTMLAudioElement | null = null;
|
||||||
|
let callJingleGeneration = 0;
|
||||||
|
|
||||||
const CALL_SECRET_VERSION = 1;
|
const CALL_SECRET_VERSION = 1;
|
||||||
const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320;
|
const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320;
|
||||||
|
|
@ -161,6 +167,31 @@ const SCREEN_SHARE_PREVIEW_MAX_HEIGHT = 180;
|
||||||
const SCREEN_SHARE_PREVIEW_QUALITY = 0.7;
|
const SCREEN_SHARE_PREVIEW_QUALITY = 0.7;
|
||||||
const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000;
|
const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000;
|
||||||
|
|
||||||
|
async function startCallJingle(shouldPlay: () => boolean) {
|
||||||
|
const generation = ++callJingleGeneration;
|
||||||
|
stopSound(callJingle);
|
||||||
|
callJingle = null;
|
||||||
|
|
||||||
|
const jingle = await requireRuntime(useCall.getState().runtime).load(
|
||||||
|
"settings.call_jingle",
|
||||||
|
);
|
||||||
|
|
||||||
|
if (generation !== callJingleGeneration || !shouldPlay()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
callJingle = playSound(
|
||||||
|
jingle === "jingle_2" ? "call_jingle_2" : "call_jingle_1",
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopCallJingle() {
|
||||||
|
callJingleGeneration += 1;
|
||||||
|
stopSound(callJingle);
|
||||||
|
callJingle = null;
|
||||||
|
}
|
||||||
|
|
||||||
function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
|
||||||
return new Uint8Array(bytes);
|
return new Uint8Array(bytes);
|
||||||
}
|
}
|
||||||
|
|
@ -273,11 +304,21 @@ function matchesRemoteTrackSelector(
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncRemoteParticipantTrackSubscriptions(participantId: number) {
|
function syncRemoteParticipantTrackSubscriptions(participantId: number) {
|
||||||
|
const state = useCall.getState();
|
||||||
|
const watchesScreen =
|
||||||
|
state.watchedStreamParticipantIds.includes(participantId);
|
||||||
|
const cameraDisabled =
|
||||||
|
state.disabledCameraParticipantIds.includes(participantId);
|
||||||
|
|
||||||
for (const publication of getRemoteTrackPublications(participantId)) {
|
for (const publication of getRemoteTrackPublications(participantId)) {
|
||||||
publication.setSubscribed(
|
const subscribed =
|
||||||
publication.kind === Track.Kind.Audio &&
|
publication.source === Track.Source.Camera
|
||||||
publication.source !== Track.Source.ScreenShareAudio,
|
? !cameraDisabled
|
||||||
);
|
: publication.source === Track.Source.ScreenShare ||
|
||||||
|
publication.source === Track.Source.ScreenShareAudio
|
||||||
|
? watchesScreen
|
||||||
|
: publication.kind === Track.Kind.Audio;
|
||||||
|
publication.setSubscribed(subscribed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -567,11 +608,13 @@ export function getRoomMetadata() {
|
||||||
|
|
||||||
// Sync local participant flags and screen-share derived state for the active call UI.
|
// Sync local participant flags and screen-share derived state for the active call UI.
|
||||||
export function syncParticipantState() {
|
export function syncParticipantState() {
|
||||||
const { screenShareSession } = useCall.getState();
|
const { cameraSession, screenShareSession } = useCall.getState();
|
||||||
const room = getRoom();
|
const room = getRoom();
|
||||||
|
|
||||||
useCall.setState({
|
useCall.setState({
|
||||||
micEnabled: room.localParticipant.isMicrophoneEnabled,
|
micEnabled: room.localParticipant.isMicrophoneEnabled,
|
||||||
|
cameraEnabled:
|
||||||
|
cameraSession != null || room.localParticipant.isCameraEnabled,
|
||||||
screenShareEnabled:
|
screenShareEnabled:
|
||||||
screenShareSession != null || room.localParticipant.isScreenShareEnabled,
|
screenShareSession != null || room.localParticipant.isScreenShareEnabled,
|
||||||
isEncrypted:
|
isEncrypted:
|
||||||
|
|
@ -695,6 +738,16 @@ export async function sendCallInvite(userId: number) {
|
||||||
// Start tracking a participant's shared screen in the call UI.
|
// Start tracking a participant's shared screen in the call UI.
|
||||||
export function startWatchingStream(participantId: number) {
|
export function startWatchingStream(participantId: number) {
|
||||||
const trackReady = getScreenShareTrackForParticipant(participantId) != null;
|
const trackReady = getScreenShareTrackForParticipant(participantId) != null;
|
||||||
|
const alreadyWatching = useCall
|
||||||
|
.getState()
|
||||||
|
.watchedStreamParticipantIds.includes(participantId);
|
||||||
|
const localParticipantId = getParticipantId(
|
||||||
|
getRoom().localParticipant.identity,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!alreadyWatching && participantId !== localParticipantId) {
|
||||||
|
playSound("stream_watch_start");
|
||||||
|
}
|
||||||
|
|
||||||
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare);
|
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare);
|
||||||
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShareAudio);
|
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShareAudio);
|
||||||
|
|
@ -730,6 +783,20 @@ export function setParticipantTrackSubscribed(
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function setParticipantCameraDisabled(
|
||||||
|
participantId: number,
|
||||||
|
disabled: boolean,
|
||||||
|
) {
|
||||||
|
useCall.setState((state) => ({
|
||||||
|
disabledCameraParticipantIds: disabled
|
||||||
|
? state.disabledCameraParticipantIds.includes(participantId)
|
||||||
|
? state.disabledCameraParticipantIds
|
||||||
|
: [...state.disabledCameraParticipantIds, participantId]
|
||||||
|
: state.disabledCameraParticipantIds.filter((id) => id !== participantId),
|
||||||
|
}));
|
||||||
|
setParticipantTrackSubscribed(participantId, Track.Source.Camera, !disabled);
|
||||||
|
}
|
||||||
|
|
||||||
// Focus a participant in the main call view even when they are not sharing a screen.
|
// Focus a participant in the main call view even when they are not sharing a screen.
|
||||||
export function focusParticipant(
|
export function focusParticipant(
|
||||||
participantId: number,
|
participantId: number,
|
||||||
|
|
@ -745,6 +812,17 @@ export function focusParticipant(
|
||||||
|
|
||||||
// Stop tracking a participant's shared screen and clean up related UI state.
|
// Stop tracking a participant's shared screen and clean up related UI state.
|
||||||
export function stopWatchingStream(participantId: number) {
|
export function stopWatchingStream(participantId: number) {
|
||||||
|
const wasWatching = useCall
|
||||||
|
.getState()
|
||||||
|
.watchedStreamParticipantIds.includes(participantId);
|
||||||
|
const localParticipantId = getParticipantId(
|
||||||
|
getRoom().localParticipant.identity,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (wasWatching && participantId !== localParticipantId) {
|
||||||
|
playSound("stream_watch_end");
|
||||||
|
}
|
||||||
|
|
||||||
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare, false);
|
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare, false);
|
||||||
setParticipantTrackSubscribed(
|
setParticipantTrackSubscribed(
|
||||||
participantId,
|
participantId,
|
||||||
|
|
@ -785,9 +863,8 @@ export function stopWatchingFocusedStream() {
|
||||||
stopWatchingStream(focusedParticipantId);
|
stopWatchingStream(focusedParticipantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let screenShareController: ReturnType<
|
let mediaShareController: ReturnType<typeof createMediaShareController> | null =
|
||||||
typeof createScreenShareController
|
null;
|
||||||
> | null = null;
|
|
||||||
|
|
||||||
function getNoiseFilterAssetBaseUrl() {
|
function getNoiseFilterAssetBaseUrl() {
|
||||||
if (window.location.protocol === "file:") {
|
if (window.location.protocol === "file:") {
|
||||||
|
|
@ -797,17 +874,21 @@ function getNoiseFilterAssetBaseUrl() {
|
||||||
return "/assets";
|
return "/assets";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getScreenShareController() {
|
function getMediaShareController() {
|
||||||
if (!screenShareController) {
|
if (!mediaShareController) {
|
||||||
screenShareController = createScreenShareController({
|
mediaShareController = createMediaShareController({
|
||||||
room: getRoom(),
|
room: getRoom(),
|
||||||
getState: () => ({
|
getState: () => ({
|
||||||
screenShareSession: useCall.getState().screenShareSession,
|
screenShareSession: useCall.getState().screenShareSession,
|
||||||
|
cameraSession: useCall.getState().cameraSession,
|
||||||
}),
|
}),
|
||||||
setState: (updater) => {
|
setState: (updater) => {
|
||||||
useCall.setState((state) =>
|
useCall.setState((state) =>
|
||||||
typeof updater === "function"
|
typeof updater === "function"
|
||||||
? updater({ screenShareSession: state.screenShareSession })
|
? updater({
|
||||||
|
screenShareSession: state.screenShareSession,
|
||||||
|
cameraSession: state.cameraSession,
|
||||||
|
})
|
||||||
: updater,
|
: updater,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -819,7 +900,7 @@ function getScreenShareController() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return screenShareController;
|
return mediaShareController;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Connect to LiveKit, enable the microphone, and move the UI into the live call.
|
// Connect to LiveKit, enable the microphone, and move the UI into the live call.
|
||||||
|
|
@ -866,11 +947,12 @@ export async function connect(callId: string) {
|
||||||
|
|
||||||
// Tear down the active call session and return the store to a closed state.
|
// Tear down the active call session and return the store to a closed state.
|
||||||
export async function disconnect() {
|
export async function disconnect() {
|
||||||
|
stopCallJingle();
|
||||||
disposeSpeakingDetector();
|
disposeSpeakingDetector();
|
||||||
await clearScreenSharePreview();
|
await clearScreenSharePreview();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getScreenShareController().clearPublishedScreenShare();
|
await getMediaShareController().clearAll();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(
|
log(
|
||||||
1,
|
1,
|
||||||
|
|
@ -892,6 +974,9 @@ export async function disconnect() {
|
||||||
deaf: false,
|
deaf: false,
|
||||||
view: "preview",
|
view: "preview",
|
||||||
screenShareSession: null,
|
screenShareSession: null,
|
||||||
|
cameraSession: null,
|
||||||
|
cameraEnabled: false,
|
||||||
|
disabledCameraParticipantIds: [],
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
focusedParticipantType: null,
|
focusedParticipantType: null,
|
||||||
usersInFocusedViewHidden: false,
|
usersInFocusedViewHidden: false,
|
||||||
|
|
@ -939,6 +1024,10 @@ export async function joinCall(
|
||||||
ownCallSecretInvitePending: isNewCall,
|
ownCallSecretInvitePending: isNewCall,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (sendInvite && !existingCallId) {
|
||||||
|
void startCallJingle(() => useCall.getState().invitedUserId != null);
|
||||||
|
}
|
||||||
|
|
||||||
if (callSecret) {
|
if (callSecret) {
|
||||||
try {
|
try {
|
||||||
if (!existingCallId) {
|
if (!existingCallId) {
|
||||||
|
|
@ -1015,37 +1104,37 @@ export async function toggleMute() {
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start browser-native screen sharing for the current participant.
|
export async function startScreenShare(
|
||||||
export async function startScreenShare(options?: ScreenShareCaptureOptions) {
|
request: Omit<MediaShareRequest, "kind"> = {},
|
||||||
await getScreenShareController().startScreenShare(options);
|
) {
|
||||||
|
await getMediaShareController().start({ ...request, kind: "screen" });
|
||||||
await publishScreenSharePreview();
|
await publishScreenSharePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start the Linux desktop capture path that renders frames through Tauri.
|
export async function startCameraShare(sourceId?: string) {
|
||||||
export async function startLinuxDesktopScreenShare(sourceId: string) {
|
await getMediaShareController().start({ kind: "camera", sourceId });
|
||||||
await getScreenShareController().startLinuxDesktopScreenShare(sourceId);
|
|
||||||
await publishScreenSharePreview();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Stop the local participant's active screen share and related previews.
|
// Stop the local participant's active screen share and related previews.
|
||||||
export async function stopScreenShare() {
|
export async function stopScreenShare() {
|
||||||
await getScreenShareController().stopScreenShare();
|
await getMediaShareController().stop("screen");
|
||||||
await clearScreenSharePreview();
|
await clearScreenSharePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function stopCameraShare() {
|
||||||
|
await getMediaShareController().stop("camera");
|
||||||
|
}
|
||||||
|
|
||||||
// Toggle screen sharing on or off from UI controls.
|
// Toggle screen sharing on or off from UI controls.
|
||||||
export async function setScreenShareEnabled(
|
export async function setScreenShareEnabled(
|
||||||
enabled: boolean,
|
enabled: boolean,
|
||||||
options?: ScreenShareCaptureOptions,
|
request: Omit<MediaShareRequest, "kind"> = {},
|
||||||
) {
|
) {
|
||||||
await getScreenShareController().setScreenShareEnabled(enabled, options);
|
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
await publishScreenSharePreview();
|
await startScreenShare(request);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
await stopScreenShare();
|
||||||
await clearScreenSharePreview();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Reset the in-memory call store when leaving the call experience entirely.
|
// Reset the in-memory call store when leaving the call experience entirely.
|
||||||
|
|
@ -1060,7 +1149,10 @@ export function resetCallState() {
|
||||||
livekitToken: null,
|
livekitToken: null,
|
||||||
currentCallData: null,
|
currentCallData: null,
|
||||||
deaf: false,
|
deaf: false,
|
||||||
|
cameraEnabled: false,
|
||||||
screenShareSession: null,
|
screenShareSession: null,
|
||||||
|
cameraSession: null,
|
||||||
|
disabledCameraParticipantIds: [],
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
focusedParticipantType: null,
|
focusedParticipantType: null,
|
||||||
usersInFocusedViewHidden: false,
|
usersInFocusedViewHidden: false,
|
||||||
|
|
@ -1114,8 +1206,11 @@ export const useCall = create<CallStore>(() => ({
|
||||||
currentCallData: null,
|
currentCallData: null,
|
||||||
deaf: false,
|
deaf: false,
|
||||||
micEnabled: false,
|
micEnabled: false,
|
||||||
|
cameraEnabled: false,
|
||||||
screenShareEnabled: false,
|
screenShareEnabled: false,
|
||||||
screenShareSession: null,
|
screenShareSession: null,
|
||||||
|
cameraSession: null,
|
||||||
|
disabledCameraParticipantIds: [],
|
||||||
focusedParticipantId: null,
|
focusedParticipantId: null,
|
||||||
focusedParticipantType: null,
|
focusedParticipantType: null,
|
||||||
usersInFocusedViewHidden: false,
|
usersInFocusedViewHidden: false,
|
||||||
|
|
@ -1174,12 +1269,14 @@ export function useInitializeCall() {
|
||||||
useCall.setState({
|
useCall.setState({
|
||||||
incomingCallInvite: { callId, callSecret, senderId },
|
incomingCallInvite: { callId, callSecret, senderId },
|
||||||
});
|
});
|
||||||
|
void startCallJingle(() => useCall.getState().incomingCallInvite != null);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
const setInvitePopupOpen = useCallback((open: boolean) => {
|
const setInvitePopupOpen = useCallback((open: boolean) => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
|
stopCallJingle();
|
||||||
useCall.setState({ incomingCallInvite: null });
|
useCall.setState({ incomingCallInvite: null });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -1188,6 +1285,7 @@ export function useInitializeCall() {
|
||||||
(accepted: boolean) => {
|
(accepted: boolean) => {
|
||||||
const invite = useCall.getState().incomingCallInvite;
|
const invite = useCall.getState().incomingCallInvite;
|
||||||
|
|
||||||
|
stopCallJingle();
|
||||||
useCall.setState({ incomingCallInvite: null });
|
useCall.setState({ incomingCallInvite: null });
|
||||||
|
|
||||||
if (!invite) {
|
if (!invite) {
|
||||||
|
|
@ -1235,6 +1333,11 @@ export function useInitializeCall() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const currentCall = useCall.getState();
|
||||||
|
if (currentCall.callId === CallId && currentCall.state !== "closed") {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
showCallingScreen(
|
showCallingScreen(
|
||||||
CallId,
|
CallId,
|
||||||
normalizeWrappedCallSecret(CallSecret),
|
normalizeWrappedCallSecret(CallSecret),
|
||||||
|
|
@ -1303,6 +1406,7 @@ export function useInitializeCall() {
|
||||||
|
|
||||||
const onConnected = async () => {
|
const onConnected = async () => {
|
||||||
useCall.setState({ state: "open" });
|
useCall.setState({ state: "open" });
|
||||||
|
playSound("call_join");
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
|
|
||||||
const detector = getSpeakingDetector();
|
const detector = getSpeakingDetector();
|
||||||
|
|
@ -1376,6 +1480,8 @@ export function useInitializeCall() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onDisconnected = () => {
|
const onDisconnected = () => {
|
||||||
|
stopCallJingle();
|
||||||
|
playSound("call_leave");
|
||||||
useCall.setState({ state: "closed" });
|
useCall.setState({ state: "closed" });
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
log(2, "call", "purple", "Disconnected from call", {
|
log(2, "call", "purple", "Disconnected from call", {
|
||||||
|
|
@ -1385,11 +1491,14 @@ export function useInitializeCall() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onParticipantConnected = () => {
|
const onParticipantConnected = () => {
|
||||||
|
stopCallJingle();
|
||||||
|
playSound("call_join");
|
||||||
syncAllRemoteTrackSubscriptions();
|
syncAllRemoteTrackSubscriptions();
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
};
|
};
|
||||||
|
|
||||||
const onParticipantDisconnected = (participant: Participant) => {
|
const onParticipantDisconnected = (participant: Participant) => {
|
||||||
|
playSound("call_leave");
|
||||||
const participantId = getParticipantId(participant.identity);
|
const participantId = getParticipantId(participant.identity);
|
||||||
|
|
||||||
if (participantId != null) {
|
if (participantId != null) {
|
||||||
|
|
@ -1416,6 +1525,10 @@ export function useInitializeCall() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLocalTrackPublished = (publication: LocalTrackPublication) => {
|
const onLocalTrackPublished = (publication: LocalTrackPublication) => {
|
||||||
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
|
playSound("stream_start_self");
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
publication.kind === Track.Kind.Audio &&
|
publication.kind === Track.Kind.Audio &&
|
||||||
publication.source === Track.Source.Microphone &&
|
publication.source === Track.Source.Microphone &&
|
||||||
|
|
@ -1437,6 +1550,10 @@ export function useInitializeCall() {
|
||||||
};
|
};
|
||||||
|
|
||||||
const onLocalTrackUnpublished = (publication: LocalTrackPublication) => {
|
const onLocalTrackUnpublished = (publication: LocalTrackPublication) => {
|
||||||
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
|
playSound("stream_end_self");
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
publication.kind === Track.Kind.Audio &&
|
publication.kind === Track.Kind.Audio &&
|
||||||
publication.source === Track.Source.Microphone
|
publication.source === Track.Source.Microphone
|
||||||
|
|
@ -1453,17 +1570,22 @@ export function useInitializeCall() {
|
||||||
publication: RemoteTrackPublication,
|
publication: RemoteTrackPublication,
|
||||||
participant: RemoteParticipant,
|
participant: RemoteParticipant,
|
||||||
) => {
|
) => {
|
||||||
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
|
playSound("stream_start_other");
|
||||||
|
}
|
||||||
|
|
||||||
const participantId = getParticipantId(participant.identity);
|
const participantId = getParticipantId(participant.identity);
|
||||||
|
|
||||||
if (participantId != null) {
|
if (participantId != null) {
|
||||||
if (
|
syncRemoteParticipantTrackSubscriptions(participantId);
|
||||||
publication.kind === Track.Kind.Audio &&
|
}
|
||||||
publication.source !== Track.Source.ScreenShareAudio
|
|
||||||
) {
|
onParticipantStateChange();
|
||||||
publication.setSubscribed(true);
|
};
|
||||||
} else {
|
|
||||||
publication.setSubscribed(false);
|
const onTrackUnpublished = (publication: RemoteTrackPublication) => {
|
||||||
}
|
if (publication.source === Track.Source.ScreenShare) {
|
||||||
|
playSound("stream_end_other");
|
||||||
}
|
}
|
||||||
|
|
||||||
onParticipantStateChange();
|
onParticipantStateChange();
|
||||||
|
|
@ -1526,7 +1648,7 @@ export function useInitializeCall() {
|
||||||
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
||||||
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
||||||
room.on(RoomEvent.TrackPublished, onTrackPublished);
|
room.on(RoomEvent.TrackPublished, onTrackPublished);
|
||||||
room.on(RoomEvent.TrackUnpublished, onParticipantStateChange);
|
room.on(RoomEvent.TrackUnpublished, onTrackUnpublished);
|
||||||
room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
|
room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
|
||||||
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||||
room.on(RoomEvent.TrackMuted, onParticipantStateChange);
|
room.on(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||||
|
|
@ -1547,7 +1669,7 @@ export function useInitializeCall() {
|
||||||
room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
||||||
room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
||||||
room.off(RoomEvent.TrackPublished, onTrackPublished);
|
room.off(RoomEvent.TrackPublished, onTrackPublished);
|
||||||
room.off(RoomEvent.TrackUnpublished, onParticipantStateChange);
|
room.off(RoomEvent.TrackUnpublished, onTrackUnpublished);
|
||||||
room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
|
room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
|
||||||
room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||||
room.off(RoomEvent.TrackMuted, onParticipantStateChange);
|
room.off(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||||
|
|
@ -1585,7 +1707,7 @@ export function useInitializeCall() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentCallData({
|
setCurrentCallData({
|
||||||
...(data.data as z.infer<typeof mtp.CallData.response>),
|
...mtp.CallData.response.parse(data.data),
|
||||||
exists: true,
|
exists: true,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,14 @@
|
||||||
import { RoomEvent } from "livekit-client";
|
import { RoomEvent } from "livekit-client";
|
||||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useCall, getRoom } from "../../store";
|
import { getRoom, setUsersInFocusedViewHidden, useCall } from "../../store";
|
||||||
import Base from "../../components/modals/base";
|
import Base from "../../components/modals/base";
|
||||||
|
import { useIsMobile } from "@methanium/ui";
|
||||||
|
|
||||||
const SECONDARY_ROW_HEIGHT_PX = 180;
|
const SECONDARY_ROW_HEIGHT_PX = 180;
|
||||||
const STACK_GAP_PX = 12;
|
const STACK_GAP_PX = 12;
|
||||||
|
|
||||||
export default function View() {
|
export default function View() {
|
||||||
|
const isMobile = useIsMobile();
|
||||||
const room = getRoom();
|
const room = getRoom();
|
||||||
const layoutVersion = useCall((state) => state.layoutVersion);
|
const layoutVersion = useCall((state) => state.layoutVersion);
|
||||||
|
|
||||||
|
|
@ -27,6 +29,10 @@ export default function View() {
|
||||||
const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false);
|
const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false);
|
||||||
const [participantVersion, setParticipantVersion] = useState(0);
|
const [participantVersion, setParticipantVersion] = useState(0);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isMobile) setUsersInFocusedViewHidden(true);
|
||||||
|
}, [isMobile]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const syncParticipants = () => {
|
const syncParticipants = () => {
|
||||||
setParticipantVersion((version) => version + 1);
|
setParticipantVersion((version) => version + 1);
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { RoomEvent } from "livekit-client";
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { useCall, getRoom } from "../../store";
|
import { useCall, getRoom } from "../../store";
|
||||||
import Base from "../../components/modals/base";
|
import Base from "../../components/modals/base";
|
||||||
|
import { cn, useIsMobile } from "@methanium/ui";
|
||||||
|
|
||||||
const TILE_ASPECT_RATIO = 16 / 9;
|
const TILE_ASPECT_RATIO = 16 / 9;
|
||||||
const GRID_GAP = 12;
|
const GRID_GAP = 12;
|
||||||
|
|
@ -214,6 +215,8 @@ export default function View() {
|
||||||
return room.getParticipantByIdentity(String(participantId));
|
return room.getParticipantByIdentity(String(participantId));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={containerRef}
|
ref={containerRef}
|
||||||
|
|
@ -221,7 +224,7 @@ export default function View() {
|
||||||
height: "calc(100% - 2rem)",
|
height: "calc(100% - 2rem)",
|
||||||
width: "calc(100% - 2rem)",
|
width: "calc(100% - 2rem)",
|
||||||
}}
|
}}
|
||||||
className="overflow-hidden p-3"
|
className={cn("overflow-hidden", isMobile ? "" : "p-3")}
|
||||||
>
|
>
|
||||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
|
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
|
||||||
{rows.map((row) => (
|
{rows.map((row) => (
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,7 @@ import {
|
||||||
triggerCallLayoutCalculation,
|
triggerCallLayoutCalculation,
|
||||||
useCall,
|
useCall,
|
||||||
} from "../../store";
|
} from "../../store";
|
||||||
|
import { useIsMobile } from "@methanium/ui";
|
||||||
|
|
||||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
const screenRef = useRef<HTMLDivElement>(null);
|
const screenRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
@ -129,6 +130,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
setIsImmersiveChromeVisible(false);
|
setIsImmersiveChromeVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const isMobile = useIsMobile();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={screenRef}
|
ref={screenRef}
|
||||||
|
|
@ -139,23 +142,25 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
onMouseMove={showImmersiveChrome}
|
onMouseMove={showImmersiveChrome}
|
||||||
onMouseLeave={hideImmersiveChrome}
|
onMouseLeave={hideImmersiveChrome}
|
||||||
>
|
>
|
||||||
<div
|
{!isMobile && (
|
||||||
ref={topBarRef}
|
<div
|
||||||
className={`shrink-0 w-full z-40 transition-all duration-200 ${
|
ref={topBarRef}
|
||||||
isImmersiveFocusedView
|
className={`shrink-0 w-full z-40 transition-all duration-200 ${
|
||||||
? immersiveChromeVisible
|
isImmersiveFocusedView
|
||||||
? "opacity-100 translate-y-0 pointer-events-auto"
|
? immersiveChromeVisible
|
||||||
: "opacity-0 -translate-y-2 pointer-events-none"
|
? "opacity-100 translate-y-0 pointer-events-auto"
|
||||||
: "opacity-100 translate-y-0"
|
: "opacity-0 -translate-y-2 pointer-events-none"
|
||||||
}`}
|
: "opacity-100 translate-y-0"
|
||||||
style={{
|
}`}
|
||||||
position: usersInFocusedViewHidden ? "absolute" : "relative",
|
style={{
|
||||||
top: 0,
|
position: usersInFocusedViewHidden ? "absolute" : "relative",
|
||||||
left: 0,
|
top: 0,
|
||||||
}}
|
left: 0,
|
||||||
>
|
}}
|
||||||
<TopBar />
|
>
|
||||||
</div>
|
<TopBar />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div
|
<div
|
||||||
className="min-h-0 flex-1 w-full flex justify-center items-center overflow-hidden"
|
className="min-h-0 flex-1 w-full flex justify-center items-center overflow-hidden"
|
||||||
style={
|
style={
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
- Overlay for stream modals
|
- Overlay for stream modals
|
||||||
- Mobile
|
- Mobile
|
||||||
- Sounds
|
|
||||||
- Admin call actions
|
- Admin call actions
|
||||||
- Timeout
|
- Timeout
|
||||||
- Disconnect
|
- Disconnect
|
||||||
|
|
@ -8,3 +7,4 @@
|
||||||
- Add quality selection
|
- Add quality selection
|
||||||
- Good preview page
|
- Good preview page
|
||||||
- Settings page
|
- Settings page
|
||||||
|
- Make the Three-Dots button open a dialog/context-menu to configure the call for anonymous invites or other admin stuff
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,7 @@
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/shared": "workspace:*",
|
"@tensamin/shared": "workspace:*",
|
||||||
"@tensamin/storage": "workspace:*",
|
"@tensamin/storage": "workspace:*",
|
||||||
"@tensamin/ui": "*",
|
"@methanium/ui": "*",
|
||||||
"@tensamin/user": "workspace:*",
|
"@tensamin/user": "workspace:*",
|
||||||
"lucide-react": "^1.14.0",
|
"lucide-react": "^1.14.0",
|
||||||
"motion": "^12.42.2",
|
"motion": "^12.42.2",
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button } from "@methanium/ui";
|
||||||
import Emoji from "@tensamin/markdown/emoji";
|
import Emoji from "@tensamin/markdown/emoji";
|
||||||
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
TabsContent,
|
TabsContent,
|
||||||
TabsList,
|
TabsList,
|
||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { Loader2, Search } from "lucide-react";
|
import { Loader2, Search } from "lucide-react";
|
||||||
import MediaSaveButton from "./mediaSaveButton";
|
import MediaSaveButton from "./mediaSaveButton";
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,27 @@
|
||||||
import Input from "@tensamin/markdown/input";
|
import Input from "@tensamin/markdown/input";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
Card,
|
Card,
|
||||||
CardHeader,
|
CardHeader,
|
||||||
Popover,
|
Popover,
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
Skeleton,
|
} from "@methanium/ui";
|
||||||
} from "@tensamin/ui";
|
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import React, { useEffect, useState, useRef } from "react";
|
import React, { useEffect, useState, useRef } from "react";
|
||||||
import { Button } from "@tensamin/ui";
|
import { Button } from "@methanium/ui";
|
||||||
|
|
||||||
import { Plus, Laugh, FileVideo, Forward, X } from "lucide-react";
|
import { Plus, Laugh, FileVideo } from "lucide-react";
|
||||||
import { useChat, useReplyMessage } from "../context";
|
import { useChat, useReplyMessage } from "../context";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import { cn, useIsMobile } from "@tensamin/ui";
|
import { cn, useIsMobile } from "@methanium/ui";
|
||||||
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||||
|
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import GifPicker from "./gifPicker";
|
import GifPicker from "./gifPicker";
|
||||||
import EmojiPicker from "./emojiPicker";
|
import EmojiPicker from "./emojiPicker";
|
||||||
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
import ReplyBox from "./replyBox";
|
||||||
import Text from "@tensamin/markdown/text";
|
|
||||||
|
|
||||||
export default function InputComponent({
|
export default function InputComponent({
|
||||||
value,
|
value,
|
||||||
|
|
@ -117,6 +112,7 @@ export default function InputComponent({
|
||||||
Content: encryptedContent,
|
Content: encryptedContent,
|
||||||
ReceiverId: userId,
|
ReceiverId: userId,
|
||||||
SendTime: time,
|
SendTime: time,
|
||||||
|
...(replyTo && { ReplyId: replyTo }),
|
||||||
}).catch((e) => {
|
}).catch((e) => {
|
||||||
log(0, "Chat", "red", "Failed to send message", e, {
|
log(0, "Chat", "red", "Failed to send message", e, {
|
||||||
ReceiverId: userId,
|
ReceiverId: userId,
|
||||||
|
|
@ -196,54 +192,15 @@ export default function InputComponent({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
|
{/* reply */}
|
||||||
{replyTo !== undefined && (
|
{replyTo !== undefined && (
|
||||||
<div className="self-center rounded-t-lg bg-background border-t border-x flex items-center justify-start w-[90%] p-1">
|
<ReplyBox
|
||||||
<div className="flex gap-1 w-full items-center">
|
content={replyMessage?.Content}
|
||||||
<Forward
|
loading={!replyMessage}
|
||||||
color="var(--muted-foreground)"
|
onDismiss={() => setReplyTo(undefined)}
|
||||||
className="opacity-60"
|
userId={replyUserId ?? ownId}
|
||||||
size={18}
|
variant="composer"
|
||||||
/>
|
/>
|
||||||
{replyMessage ? (
|
|
||||||
<>
|
|
||||||
<Wrapper
|
|
||||||
userId={replyUserId ?? ownId}
|
|
||||||
loading={<Skeleton className="rounded-full" />}
|
|
||||||
component={(user) => (
|
|
||||||
<Avatar>
|
|
||||||
<AvatarImage src={user.Avatar} />
|
|
||||||
<AvatarFallback>
|
|
||||||
{user.Display.slice(2, 0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<div className="max-w-full max-h-5 h-5 text-xs truncate">
|
|
||||||
<Text value={replyMessage.Content} />
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<Skeleton className="w-5 h-5 aspect-square rounded-full" />
|
|
||||||
<Skeleton className="w-full h-5" />
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
onClick={() => {
|
|
||||||
setReplyTo(undefined);
|
|
||||||
}}
|
|
||||||
className="w-5 h-5 rounded-md! p-0!"
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<X
|
|
||||||
color="var(--muted-foreground)"
|
|
||||||
className="opacity-60"
|
|
||||||
size={18}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
<Card
|
<Card
|
||||||
ref={inputBoxRef}
|
ref={inputBoxRef}
|
||||||
|
|
@ -267,7 +224,7 @@ export default function InputComponent({
|
||||||
/>
|
/>
|
||||||
<div className="w-full flex justify-between gap-1 p-1 pt-0">
|
<div className="w-full flex justify-between gap-1 p-1 pt-0">
|
||||||
<div className="flex gap-1">
|
<div className="flex gap-1">
|
||||||
<Button className="w-9 h-9 p-0" variant="ghost">
|
<Button className="w-9 h-9! p-0" variant="ghost">
|
||||||
<Plus size={20} />
|
<Plus size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
<div className="w-auto flex text-red-500">
|
<div className="w-auto flex text-red-500">
|
||||||
|
|
@ -280,15 +237,16 @@ export default function InputComponent({
|
||||||
onOpenChange={setEmojiPopoverOpen}
|
onOpenChange={setEmojiPopoverOpen}
|
||||||
>
|
>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={({ onClick }) => (
|
||||||
<Button
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
aria-label="Open emoji picker"
|
aria-label="Open emoji picker"
|
||||||
className="w-9 h-9 p-0"
|
className="w-9 h-9! p-0"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
>
|
>
|
||||||
<Laugh size={20} />
|
<Laugh size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
<PopoverContent className="w-auto p-0">
|
<PopoverContent className="w-auto p-0">
|
||||||
<EmojiPicker
|
<EmojiPicker
|
||||||
|
|
@ -302,11 +260,15 @@ export default function InputComponent({
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={({ onClick }) => (
|
||||||
<Button className="w-9 h-9 p-0" variant="ghost">
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
|
className="w-9 h-9! p-0"
|
||||||
|
variant="ghost"
|
||||||
|
>
|
||||||
<FileVideo size={20} />
|
<FileVideo size={20} />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
<PopoverContent
|
<PopoverContent
|
||||||
ref={gifPopoverRef}
|
ref={gifPopoverRef}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import Text from "@tensamin/markdown/text";
|
import Text from "@tensamin/markdown/text";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
|
||||||
import { TriangleAlert } from "lucide-react";
|
import { TriangleAlert } from "lucide-react";
|
||||||
import { useState, useMemo, useEffect } from "react";
|
import { useState, useMemo, useEffect } from "react";
|
||||||
import MediaSaveButton from "./mediaSaveButton";
|
import MediaSaveButton from "./mediaSaveButton";
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { Button, cn } from "@tensamin/ui";
|
import { Button, cn } from "@methanium/ui";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { Save } from "lucide-react";
|
import { Save } from "lucide-react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
import * as React from "react";
|
|
||||||
import type { RawMessage } from "../values";
|
import type { RawMessage } from "../values";
|
||||||
import Text from "@tensamin/markdown/text";
|
import Text from "@tensamin/markdown/text";
|
||||||
import { AlertTriangle, Check, Ellipse, RefreshCw } from "lucide-react";
|
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { memo, useCallback, useEffect, useState } from "react";
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -12,17 +11,19 @@ import {
|
||||||
Button,
|
Button,
|
||||||
cn,
|
cn,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import MessageContextMenu from "./messageContextMenu";
|
import MessageContextMenu from "./messageContextMenu";
|
||||||
import Media from "./media";
|
import Media from "./media";
|
||||||
import { useStorage } from "@tensamin/storage/context";
|
import { useStorage } from "@tensamin/storage/context";
|
||||||
import { useMTP } from "@tensamin/mtp";
|
import { useMTP } from "@tensamin/mtp";
|
||||||
import Input from "@tensamin/markdown/input";
|
import Input from "@tensamin/markdown/input";
|
||||||
import { useChat } from "../context";
|
import { getMessage, useChat } from "../context";
|
||||||
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||||
import { log, toast } from "@tensamin/shared/log";
|
import { log, toast } from "@tensamin/shared/log";
|
||||||
import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
|
import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||||
import { useRecordEmojiUse } from "./emojiRanks";
|
import { useRecordEmojiUse } from "./emojiRanks";
|
||||||
|
import { useUser } from "@tensamin/user/context";
|
||||||
|
import ReplyBox from "./replyBox";
|
||||||
|
|
||||||
function MessageComponent({
|
function MessageComponent({
|
||||||
grouped,
|
grouped,
|
||||||
|
|
@ -142,7 +143,45 @@ function MessageComponent({
|
||||||
userId,
|
userId,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
removeReaction,
|
removeReaction,
|
||||||
|
replyTo,
|
||||||
} = useChat();
|
} = useChat();
|
||||||
|
const { get: getUser } = useUser();
|
||||||
|
const [replyMessage, setReplyMessage] = useState<RawMessage | null>(null);
|
||||||
|
const [replyUser, setReplyUser] = useState<User | null>(null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (!message.ReplyId || !ownId || !chatSecret) {
|
||||||
|
setReplyMessage(null);
|
||||||
|
setReplyUser(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let active = true;
|
||||||
|
void getMessage({
|
||||||
|
sendTime: message.ReplyId,
|
||||||
|
ownId,
|
||||||
|
chatPartnerId: userId,
|
||||||
|
send,
|
||||||
|
})
|
||||||
|
.then(async (reply) => {
|
||||||
|
const [Content, author] = await Promise.all([
|
||||||
|
decryptChatText(chatSecret, reply.Content),
|
||||||
|
getUser(reply.SenderId),
|
||||||
|
]);
|
||||||
|
if (!active) return;
|
||||||
|
setReplyMessage({ ...reply, Content });
|
||||||
|
setReplyUser(author);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!active) return;
|
||||||
|
setReplyMessage(null);
|
||||||
|
setReplyUser(null);
|
||||||
|
log(1, "chat", "red", "Failed to get replied-to message", err);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
active = false;
|
||||||
|
};
|
||||||
|
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
|
||||||
const recordUse = useRecordEmojiUse();
|
const recordUse = useRecordEmojiUse();
|
||||||
const [editing, setEditing] = useState(false);
|
const [editing, setEditing] = useState(false);
|
||||||
const [editDraft, setEditDraft] = useState(message.Content);
|
const [editDraft, setEditDraft] = useState(message.Content);
|
||||||
|
|
@ -233,8 +272,16 @@ function MessageComponent({
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
// pt-3 is to get a gap between messages
|
// pt-3 is to get a gap between messages
|
||||||
className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${opacityClass}`}
|
className={`${grouped ? "" : "pt-3"} w-full flex flex-col gap-1 justify-start items-start transition-opacity duration-150 ${opacityClass}`}
|
||||||
>
|
>
|
||||||
|
{/* reply */}
|
||||||
|
{replyMessage && replyUser && (
|
||||||
|
<ReplyBox
|
||||||
|
content={replyMessage.Content}
|
||||||
|
user={replyUser}
|
||||||
|
variant="message"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{user && message.Content ? (
|
{user && message.Content ? (
|
||||||
<MessageContextMenu
|
<MessageContextMenu
|
||||||
content={message.Content}
|
content={message.Content}
|
||||||
|
|
@ -244,138 +291,149 @@ function MessageComponent({
|
||||||
onReact={toggleReaction}
|
onReact={toggleReaction}
|
||||||
onSetEditing={setEditing}
|
onSetEditing={setEditing}
|
||||||
>
|
>
|
||||||
<div
|
<>
|
||||||
className={cn(
|
<div
|
||||||
"group hover:bg-muted/50 select-text! relative justify-start flex gap-1 items-center w-full px-2 whitespace-pre-wrap break-all",
|
className={cn(
|
||||||
{
|
"group hover:bg-muted/50 select-text! relative justify-start flex gap-1 items-center w-full px-2 whitespace-pre-wrap break-all transition-colors duration-200 ease-in-out",
|
||||||
"bg-(--destructive)/10 text-destructive-foreground hover:bg-(--destructive)/15":
|
{
|
||||||
actuallyFailed,
|
"bg-(--destructive)/10 text-destructive-foreground hover:bg-(--destructive)/15":
|
||||||
},
|
actuallyFailed,
|
||||||
)}
|
"bg-(--primary-foreground-alt)/15 hover:bg-(--primary-foreground-alt)/20":
|
||||||
>
|
replyTo === message.SendTime && !actuallyFailed,
|
||||||
<>
|
},
|
||||||
{grouped ? (
|
|
||||||
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
|
||||||
{new Date(message.SendTime).toLocaleString([], {
|
|
||||||
hour: "2-digit",
|
|
||||||
minute: "2-digit",
|
|
||||||
})}
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<Avatar className="mr-1 mb-auto mt-1 w-10">
|
|
||||||
<AvatarImage src={user.Avatar} />
|
|
||||||
<AvatarFallback>
|
|
||||||
{user.Display.slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
)}
|
)}
|
||||||
<div
|
>
|
||||||
className={cn("flex flex-col group", {
|
<>
|
||||||
"min-w-0 flex-1": editing,
|
{grouped ? (
|
||||||
})}
|
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
||||||
>
|
{new Date(message.SendTime).toLocaleString([], {
|
||||||
{!grouped && (
|
hour: "2-digit",
|
||||||
<div className="flex items-center gap-1">
|
minute: "2-digit",
|
||||||
<p className="font-medium">{user.Display}</p>
|
})}
|
||||||
<p className="text-xs text-muted-foreground">
|
</p>
|
||||||
{new Date(message.SendTime).toLocaleString([], {
|
) : (
|
||||||
hour: "2-digit",
|
<Avatar className="mr-1 mb-auto mt-1 w-10">
|
||||||
minute: "2-digit",
|
<AvatarImage src={user.Avatar} />
|
||||||
})}
|
<AvatarFallback>
|
||||||
</p>
|
{user.Display.slice(0, 2).toUpperCase()}
|
||||||
<div className="flex items-center justify-start gap-1">
|
</AvatarFallback>
|
||||||
{message.MessageState === "read" ? (
|
</Avatar>
|
||||||
<Check
|
)}
|
||||||
size={12}
|
<div
|
||||||
color="var(--primary-foreground-alt)"
|
className={cn("flex flex-col group", {
|
||||||
/>
|
"min-w-0 flex-1": editing,
|
||||||
) : message.MessageState === "received" ? (
|
})}
|
||||||
<Check size={12} color="var(--muted-foreground)" />
|
>
|
||||||
) : message.MessageState === "sent" ? (
|
{!grouped && (
|
||||||
<RefreshCw size={12} color="var(--muted-foreground)" />
|
<div className="flex items-center gap-1">
|
||||||
) : message.MessageState === "sending" ? (
|
<p className="font-semibold">{user.Display}</p>
|
||||||
<Ellipse size={12} color="var(--muted-foreground)" />
|
<p className="text-xs text-muted-foreground">
|
||||||
) : null}
|
{new Date(message.SendTime).toLocaleString([], {
|
||||||
{actuallyFailed && (
|
hour: "2-digit",
|
||||||
<AlertTriangle color="var(--destructive)" size={12} />
|
minute: "2-digit",
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center justify-start gap-1">
|
||||||
|
{message.SenderId !==
|
||||||
|
ownId ? null : message.MessageState === "read" ? (
|
||||||
|
<Check
|
||||||
|
size={12}
|
||||||
|
color="var(--primary-foreground-alt)"
|
||||||
|
/>
|
||||||
|
) : message.MessageState === "received" ? (
|
||||||
|
<CheckLine
|
||||||
|
size={12}
|
||||||
|
color="var(--muted-foreground)"
|
||||||
|
/>
|
||||||
|
) : message.MessageState === "sent" ? (
|
||||||
|
<Check size={12} color="var(--muted-foreground)" />
|
||||||
|
) : message.MessageState === "sending" ? (
|
||||||
|
<RefreshCw
|
||||||
|
size={12}
|
||||||
|
color="var(--muted-foreground)"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{actuallyFailed && (
|
||||||
|
<AlertTriangle color="var(--destructive)" size={12} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{editing ? (
|
||||||
|
<div className="flex w-full flex-col gap-1">
|
||||||
|
<Input
|
||||||
|
className="w-full"
|
||||||
|
styled
|
||||||
|
setValue={setEditDraft}
|
||||||
|
value={editDraft}
|
||||||
|
onSubmit={() => {
|
||||||
|
submitEditMessage(editDraft);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="link"
|
||||||
|
className="text-primary-foreground-alt"
|
||||||
|
onClick={() => {
|
||||||
|
if (editDraft === message.Content) {
|
||||||
|
deleteMessage(message.SendTime);
|
||||||
|
} else {
|
||||||
|
submitEditMessage(editDraft);
|
||||||
|
}
|
||||||
|
setEditDraft(message.Content);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="xs"
|
||||||
|
variant="link"
|
||||||
|
className="text-muted-foreground"
|
||||||
|
onClick={() => {
|
||||||
|
setEditDraft(message.Content);
|
||||||
|
setEditing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : isValidURL ? (
|
||||||
|
<Media link={message.Content} />
|
||||||
|
) : (
|
||||||
|
<Text value={message.Content} />
|
||||||
|
)}
|
||||||
|
{groupedReactions.length > 0 && (
|
||||||
|
<div className="mt-1 flex flex-wrap gap-1 pb-1">
|
||||||
|
{groupedReactions.map(
|
||||||
|
([reaction, { count, reactedByMe }]) => (
|
||||||
|
<Button
|
||||||
|
key={reaction}
|
||||||
|
aria-label={`${reactedByMe ? "Remove" : "Add"} ${reaction} reaction`}
|
||||||
|
className={cn(
|
||||||
|
"h-7 gap-2 rounded-lg py-3.5 px-1.5! border",
|
||||||
|
reactedByMe
|
||||||
|
? "border-(--primary-foreground-alt)/40!"
|
||||||
|
: "",
|
||||||
|
)}
|
||||||
|
onClick={() => void toggleReaction(reaction)}
|
||||||
|
size="xs"
|
||||||
|
variant={reactedByMe ? "subtleDefault" : "outline"}
|
||||||
|
>
|
||||||
|
<Emoji className="h-5 w-5" shortcode={reaction} />
|
||||||
|
<span className="text-sm">{count}</span>
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
)}
|
||||||
)}
|
</div>
|
||||||
{editing ? (
|
</>
|
||||||
<div className="flex w-full flex-col">
|
</div>
|
||||||
<Input
|
</>
|
||||||
className="w-full"
|
|
||||||
styled
|
|
||||||
setValue={setEditDraft}
|
|
||||||
value={editDraft}
|
|
||||||
onSubmit={() => {
|
|
||||||
submitEditMessage(editDraft);
|
|
||||||
setEditing(false);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<div className="flex">
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="link"
|
|
||||||
className="text-primary-foreground-alt"
|
|
||||||
onClick={() => {
|
|
||||||
if (editDraft === message.Content) {
|
|
||||||
deleteMessage(message.SendTime);
|
|
||||||
} else {
|
|
||||||
submitEditMessage(editDraft);
|
|
||||||
}
|
|
||||||
setEditDraft(message.Content);
|
|
||||||
setEditing(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="xs"
|
|
||||||
variant="link"
|
|
||||||
className="text-muted-foreground"
|
|
||||||
onClick={() => {
|
|
||||||
setEditDraft(message.Content);
|
|
||||||
setEditing(false);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : isValidURL ? (
|
|
||||||
<Media link={message.Content} />
|
|
||||||
) : (
|
|
||||||
<Text value={message.Content} />
|
|
||||||
)}
|
|
||||||
{groupedReactions.length > 0 && (
|
|
||||||
<div className="mt-1 flex flex-wrap gap-1 pb-1">
|
|
||||||
{groupedReactions.map(
|
|
||||||
([reaction, { count, reactedByMe }]) => (
|
|
||||||
<Button
|
|
||||||
key={reaction}
|
|
||||||
aria-label={`${reactedByMe ? "Remove" : "Add"} ${reaction} reaction`}
|
|
||||||
className={cn(
|
|
||||||
"h-7 gap-2 rounded-lg py-3.5 px-1.5! border",
|
|
||||||
reactedByMe
|
|
||||||
? "border-(--primary-foreground-alt)/40!"
|
|
||||||
: "",
|
|
||||||
)}
|
|
||||||
onClick={() => void toggleReaction(reaction)}
|
|
||||||
size="xs"
|
|
||||||
variant={reactedByMe ? "subtleDefault" : "outline"}
|
|
||||||
>
|
|
||||||
<Emoji className="h-5 w-5" shortcode={reaction} />
|
|
||||||
<span className="text-sm">{count}</span>
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
</div>
|
|
||||||
</MessageContextMenu>
|
</MessageContextMenu>
|
||||||
) : (
|
) : (
|
||||||
<Skeleton className="h-10 w-30" />
|
<Skeleton className="h-10 w-30" />
|
||||||
|
|
@ -384,11 +442,12 @@ function MessageComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default React.memo(MessageComponent, (prev, next) => {
|
export default memo(MessageComponent, (prev, next) => {
|
||||||
return (
|
return (
|
||||||
prev.message.SendTime === next.message.SendTime &&
|
prev.message.SendTime === next.message.SendTime &&
|
||||||
prev.message.Content === next.message.Content &&
|
prev.message.Content === next.message.Content &&
|
||||||
prev.message.SenderId === next.message.SenderId &&
|
prev.message.SenderId === next.message.SenderId &&
|
||||||
|
prev.message.ReplyId === next.message.ReplyId &&
|
||||||
prev.message.MessageState === next.message.MessageState &&
|
prev.message.MessageState === next.message.MessageState &&
|
||||||
prev.message.failed === next.message.failed &&
|
prev.message.failed === next.message.failed &&
|
||||||
prev.message.decryptionFailed === next.message.decryptionFailed &&
|
prev.message.decryptionFailed === next.message.decryptionFailed &&
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,7 @@ import {
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@tensamin/ui";
|
} from "@methanium/ui";
|
||||||
import {
|
import {
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
|
|
@ -35,11 +35,13 @@ import {
|
||||||
Reply,
|
Reply,
|
||||||
Trash,
|
Trash,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
import { AnimatePresence, motion } from "motion/react";
|
||||||
import { cloneElement, useMemo, useState, useSyncExternalStore } from "react";
|
import { cloneElement, useMemo, useState, useSyncExternalStore } from "react";
|
||||||
import type {
|
import type {
|
||||||
MouseEvent as ReactMouseEvent,
|
MouseEvent as ReactMouseEvent,
|
||||||
ReactElement,
|
ReactElement,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
|
Ref,
|
||||||
} from "react";
|
} from "react";
|
||||||
import { useChat } from "../context";
|
import { useChat } from "../context";
|
||||||
import Emoji from "@tensamin/markdown/emoji";
|
import Emoji from "@tensamin/markdown/emoji";
|
||||||
|
|
@ -260,18 +262,32 @@ function MiniMenuTooltip({
|
||||||
children,
|
children,
|
||||||
label,
|
label,
|
||||||
}: {
|
}: {
|
||||||
children: ReactElement;
|
children: ReactElement<{
|
||||||
|
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||||
|
ref?: Ref<HTMLElement>;
|
||||||
|
}>;
|
||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger render={children} />
|
<TooltipTrigger
|
||||||
|
render={({ ref, onClick }) =>
|
||||||
|
cloneElement(children, {
|
||||||
|
ref,
|
||||||
|
onClick: (event) => {
|
||||||
|
onClick?.(event);
|
||||||
|
children.props.onClick?.(event);
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
<TooltipContent>{label}</TooltipContent>
|
<TooltipContent>{label}</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function MiniMessageMenu({
|
function MiniMessageMenu({
|
||||||
|
fadeOut,
|
||||||
isOwnMessage,
|
isOwnMessage,
|
||||||
onDelete,
|
onDelete,
|
||||||
onEdit,
|
onEdit,
|
||||||
|
|
@ -283,6 +299,7 @@ function MiniMessageMenu({
|
||||||
onReply,
|
onReply,
|
||||||
shiftIsPressed,
|
shiftIsPressed,
|
||||||
}: {
|
}: {
|
||||||
|
fadeOut: boolean;
|
||||||
isOwnMessage: boolean;
|
isOwnMessage: boolean;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
|
@ -295,8 +312,12 @@ function MiniMessageMenu({
|
||||||
shiftIsPressed: boolean;
|
shiftIsPressed: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="absolute right-4 -top-3 z-10">
|
<motion.div
|
||||||
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-0! shadow-lg">
|
className="absolute right-4 -top-3 z-10"
|
||||||
|
exit={fadeOut ? { opacity: 0 } : undefined}
|
||||||
|
transition={fadeOut ? { duration: 0.048 } : undefined}
|
||||||
|
>
|
||||||
|
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-px! shadow-lg">
|
||||||
{quickReactions.map((emoji) => (
|
{quickReactions.map((emoji) => (
|
||||||
<MiniMenuTooltip
|
<MiniMenuTooltip
|
||||||
key={emoji}
|
key={emoji}
|
||||||
|
|
@ -319,15 +340,16 @@ function MiniMessageMenu({
|
||||||
children={
|
children={
|
||||||
usePickerTrigger ? (
|
usePickerTrigger ? (
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={
|
render={({ onClick }) => (
|
||||||
<Button
|
<Button
|
||||||
|
onClick={onClick}
|
||||||
aria-label="Add reaction"
|
aria-label="Add reaction"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
className="h-8 w-8"
|
className="h-8 w-8"
|
||||||
>
|
>
|
||||||
<Laugh />
|
<Laugh />
|
||||||
</Button>
|
</Button>
|
||||||
}
|
)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
|
|
@ -397,7 +419,7 @@ function MiniMessageMenu({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</div>
|
</motion.div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -538,16 +560,25 @@ export default function MessageContextMenu({
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const [mainDrawerOpen, setMainDrawerOpen] = useState(false);
|
const [mainDrawerOpen, setMainDrawerOpen] = useState(false);
|
||||||
|
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||||
const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false);
|
const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false);
|
||||||
const [pickerOpen, setPickerOpen] = useState(false);
|
const [pickerOpen, setPickerOpen] = useState(false);
|
||||||
|
const [pickerClosing, setPickerClosing] = useState(false);
|
||||||
|
const [fadeMiniMenu, setFadeMiniMenu] = useState(false);
|
||||||
const { ranks } = useEmojiRanks();
|
const { ranks } = useEmojiRanks();
|
||||||
const quickReactions = getRecentEmojis(ranks, 3);
|
const quickReactions = getRecentEmojis(ranks, 3);
|
||||||
const menuReactions = getRecentEmojis(ranks, 5);
|
const menuReactions = getRecentEmojis(ranks, 5);
|
||||||
|
|
||||||
function selectReaction(emoji: string) {
|
function selectReaction(emoji: string) {
|
||||||
void onReact(emoji);
|
void onReact(emoji);
|
||||||
|
setActiveMiniMenu(null);
|
||||||
setReactionDrawerOpen(false);
|
setReactionDrawerOpen(false);
|
||||||
setPickerOpen(false);
|
setPickerOpen(false);
|
||||||
|
setPickerClosing(false);
|
||||||
|
}
|
||||||
|
function setPickerVisibility(open: boolean) {
|
||||||
|
setPickerOpen(open);
|
||||||
|
setPickerClosing(!isMobile && !open);
|
||||||
}
|
}
|
||||||
const mainDrawerComponents = useMemo(
|
const mainDrawerComponents = useMemo(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -578,20 +609,28 @@ export default function MessageContextMenu({
|
||||||
onPointerLeave={scheduleMiniMenuClose}
|
onPointerLeave={scheduleMiniMenuClose}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{!isMobile && !hideMiniMenu && activeMenuId === messageId && (
|
<AnimatePresence onExitComplete={() => setFadeMiniMenu(false)}>
|
||||||
<MiniMessageMenu
|
{!isMobile &&
|
||||||
isOwnMessage={isOwnMessage}
|
!hideMiniMenu &&
|
||||||
onDelete={() => deleteMessage(messageId)}
|
(activeMenuId === messageId ||
|
||||||
onEdit={() => onSetEditing(true)}
|
contextMenuOpen ||
|
||||||
onOpenMenu={openMenu}
|
pickerOpen ||
|
||||||
onOpenPicker={() => setPickerOpen(true)}
|
pickerClosing) && (
|
||||||
usePickerTrigger={!isMobile}
|
<MiniMessageMenu
|
||||||
onReact={selectReaction}
|
fadeOut={fadeMiniMenu}
|
||||||
quickReactions={quickReactions}
|
isOwnMessage={isOwnMessage}
|
||||||
onReply={() => setReplyTo(messageId)}
|
onDelete={() => deleteMessage(messageId)}
|
||||||
shiftIsPressed={shiftIsPressed}
|
onEdit={() => onSetEditing(true)}
|
||||||
/>
|
onOpenMenu={openMenu}
|
||||||
)}
|
onOpenPicker={() => setPickerVisibility(true)}
|
||||||
|
usePickerTrigger={!isMobile}
|
||||||
|
onReact={selectReaction}
|
||||||
|
quickReactions={quickReactions}
|
||||||
|
onReply={() => setReplyTo(messageId)}
|
||||||
|
shiftIsPressed={shiftIsPressed}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -636,14 +675,14 @@ export default function MessageContextMenu({
|
||||||
emojis={menuReactions}
|
emojis={menuReactions}
|
||||||
onMore={() => {
|
onMore={() => {
|
||||||
setReactionDrawerOpen(false);
|
setReactionDrawerOpen(false);
|
||||||
setPickerOpen(true);
|
setPickerVisibility(true);
|
||||||
}}
|
}}
|
||||||
onSelect={selectReaction}
|
onSelect={selectReaction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<Drawer open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Drawer open={pickerOpen} onOpenChange={setPickerVisibility}>
|
||||||
<DrawerContent>
|
<DrawerContent>
|
||||||
<DrawerTitle className="sr-only">Choose an emoji</DrawerTitle>
|
<DrawerTitle className="sr-only">Choose an emoji</DrawerTitle>
|
||||||
<DrawerDescription className="sr-only">
|
<DrawerDescription className="sr-only">
|
||||||
|
|
@ -672,8 +711,17 @@ export default function MessageContextMenu({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
<Popover
|
||||||
<ContextMenu>
|
open={pickerOpen}
|
||||||
|
onOpenChange={(open, eventDetails) => {
|
||||||
|
setPickerVisibility(open);
|
||||||
|
setFadeMiniMenu(!open && eventDetails.reason === "outside-press");
|
||||||
|
}}
|
||||||
|
onOpenChangeComplete={(open) => {
|
||||||
|
if (!open) setPickerClosing(false);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||||||
<ContextMenuTrigger render={child} />
|
<ContextMenuTrigger render={child} />
|
||||||
<MessageMenuContent
|
<MessageMenuContent
|
||||||
components={desktopMenuComponents}
|
components={desktopMenuComponents}
|
||||||
|
|
@ -681,7 +729,7 @@ export default function MessageContextMenu({
|
||||||
devEnabled={devEnabled}
|
devEnabled={devEnabled}
|
||||||
isOwnMessage={isOwnMessage}
|
isOwnMessage={isOwnMessage}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
onAddReaction={() => setPickerOpen(true)}
|
onAddReaction={() => setPickerVisibility(true)}
|
||||||
onReact={selectReaction}
|
onReact={selectReaction}
|
||||||
reactionEmojis={menuReactions}
|
reactionEmojis={menuReactions}
|
||||||
onSetEditing={onSetEditing}
|
onSetEditing={onSetEditing}
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue