Compare commits
153 changed files with 5939 additions and 11308 deletions
|
|
@ -18,9 +18,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -45,9 +42,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -134,9 +128,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -189,9 +180,6 @@ 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,9 +19,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -46,9 +43,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -135,9 +129,6 @@ 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
|
||||||
|
|
||||||
|
|
@ -186,9 +177,6 @@ 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,5 +2,3 @@ node_modules
|
||||||
releases
|
releases
|
||||||
.fallow
|
.fallow
|
||||||
.direnv
|
.direnv
|
||||||
keystore.jks
|
|
||||||
keystore.properties
|
|
||||||
|
|
|
||||||
3
.gitmodules
vendored
3
.gitmodules
vendored
|
|
@ -1,3 +0,0 @@
|
||||||
[submodule "mtp-type-maps"]
|
|
||||||
path = mtp-type-maps
|
|
||||||
url = https://git.methanium.net/tensamin/mtp-type-maps
|
|
||||||
25
LICENSE
25
LICENSE
|
|
@ -1,15 +1,16 @@
|
||||||
Copyright (c) 2025 Methanium
|
Copyright (c) [2025] [Methanium]
|
||||||
|
|
||||||
All rights reserved.
|
All rights reserved.
|
||||||
|
|
||||||
No part of this software, source code, documentation, or
|
This software is protected by copyright. Copying, editing,
|
||||||
associated materials may be copied, reproduced, modified,
|
distributing, publicly performing, or any other use of this software
|
||||||
distributed, published, sublicensed, sold, or used to create
|
or its components, in source or binary form, is strictly prohibited without the express
|
||||||
derivative works without prior written permission from the
|
written permission of the copyright holder.
|
||||||
copyright holder.
|
|
||||||
|
|
||||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
|
FUTURE LICENSE ACCEPTANCE:
|
||||||
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
|
It is the copyright holder's intention to release this software in the future
|
||||||
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
|
under a license yet to be defined, which will, among other things,
|
||||||
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
allow private, non-commercial use. This statement does not constitute
|
||||||
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
a current license grant and does not alter the above
|
||||||
|
prohibition on use, copying, or modification. Until the formal
|
||||||
|
publication of such a future license, all rights remain
|
||||||
|
reserved.
|
||||||
|
|
|
||||||
7
TODO
7
TODO
|
|
@ -1,7 +0,0 @@
|
||||||
- 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,11 +95,7 @@
|
||||||
"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,22 +30,12 @@ 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" &&
|
||||||
|
|
@ -180,29 +170,6 @@ 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");
|
||||||
|
|
||||||
|
|
@ -383,7 +350,6 @@ async function start() {
|
||||||
verboseLog("app ready");
|
verboseLog("app ready");
|
||||||
registerIpc();
|
registerIpc();
|
||||||
registerDisplayMediaHandler();
|
registerDisplayMediaHandler();
|
||||||
registerMediaPermissionHandler();
|
|
||||||
initTray(() => mainWindow);
|
initTray(() => mainWindow);
|
||||||
await createWindow();
|
await createWindow();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,10 @@ 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()
|
||||||
|
|
@ -44,9 +48,7 @@ export function getSecureStorageStatus(): DesktopSecureStorageStatus {
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
available:
|
available: process.platform !== "linux" || backend !== "basic_text",
|
||||||
safeStorage.isEncryptionAvailable() &&
|
|
||||||
(process.platform !== "linux" || backend !== "basic_text"),
|
|
||||||
backend,
|
backend,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,14 @@ 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)
|
if (app.isPackaged) return path.join(process.resourcesPath, "icons", filename);
|
||||||
return path.join(process.resourcesPath, "icons", filename);
|
|
||||||
return path.resolve(__dirname, "../../build/icons", filename);
|
return path.resolve(__dirname, "../../build/icons", filename);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function setTrayCallStatus(inCall: boolean, iconDataUrl?: string) {
|
export function setTrayCallStatus(
|
||||||
|
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,3 +24,6 @@ dist-ssr
|
||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
.android
|
.android
|
||||||
|
|
||||||
|
/src-tauri/gen/android/keystore.properties
|
||||||
|
/src-tauri/gen/android/keystore.jks
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,10 @@
|
||||||
"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"
|
||||||
|
|
@ -31,7 +35,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:*",
|
||||||
"@methanium/ui": "*",
|
"@tensamin/ui": "*",
|
||||||
"react": "^19.2.0",
|
"react": "^19.2.0",
|
||||||
"react-dom": "^19.2.0"
|
"react-dom": "^19.2.0"
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,6 @@
|
||||||
<?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" />
|
||||||
|
|
@ -45,11 +39,6 @@
|
||||||
<!-- 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,66 +1,20 @@
|
||||||
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)
|
||||||
|
|
@ -76,84 +30,9 @@ 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
|
||||||
|
|
|
||||||
|
|
@ -1,348 +0,0 @@
|
||||||
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),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
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 "@methanium/ui";
|
import { useIsMobile } from "@tensamin/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 "@methanium/ui";
|
import { Button } from "@tensamin/ui";
|
||||||
|
|
||||||
import { toast } from "@tensamin/shared/log";
|
import { toast } from "@tensamin/shared/log";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -60,10 +60,9 @@
|
||||||
"@tensamin/mtp": "workspace:*",
|
"@tensamin/mtp": "workspace:*",
|
||||||
"@tensamin/tauth": "workspace:*",
|
"@tensamin/tauth": "workspace:*",
|
||||||
"@tensamin/markdown": "workspace:*",
|
"@tensamin/markdown": "workspace:*",
|
||||||
"@methanium/ui": "*",
|
"@tensamin/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",
|
||||||
|
|
|
||||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -6,9 +6,9 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
Button,
|
} from "@tensamin/ui";
|
||||||
} from "@methanium/ui";
|
import { Card, CardHeader } from "@tensamin/ui";
|
||||||
import { Skeleton } from "@methanium/ui";
|
import { Skeleton } from "@tensamin/ui";
|
||||||
import { getStatusColor } from "@tensamin/shared/data";
|
import { getStatusColor } from "@tensamin/shared/data";
|
||||||
|
|
||||||
export function Basic({
|
export function Basic({
|
||||||
|
|
@ -18,50 +18,42 @@ 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 (
|
||||||
<Button
|
<Card className="animate-in fade-in duration-300 rounded-xl py-0 h-12.5!">
|
||||||
render={<div />}
|
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
||||||
nativeButton={false}
|
<div className="relative shrink-0 overflow-visible">
|
||||||
variant="outline"
|
<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!"
|
<AvatarImage src={user.Avatar} />
|
||||||
>
|
<AvatarFallback>
|
||||||
<div className="relative shrink-0 overflow-visible">
|
{user.Display.slice(0, 2).toUpperCase()}
|
||||||
<Avatar>
|
</AvatarFallback>
|
||||||
<AvatarImage src={avatar} />
|
</Avatar>
|
||||||
<AvatarFallback>{display.slice(0, 2).toUpperCase()}</AvatarFallback>
|
<Tooltip>
|
||||||
</Avatar>
|
<TooltipTrigger
|
||||||
<Tooltip>
|
render={
|
||||||
<TooltipTrigger
|
<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">
|
||||||
render={
|
<div
|
||||||
<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">
|
style={{
|
||||||
<div
|
backgroundColor: getStatusColor(user.OnlineStatus),
|
||||||
style={{
|
}}
|
||||||
backgroundColor: getStatusColor(onlineStatus),
|
className="h-2.25 w-2.25 rounded-full"
|
||||||
}}
|
/>
|
||||||
className="h-2.25 w-2.25 rounded-full"
|
</div>
|
||||||
/>
|
}
|
||||||
</div>
|
/>
|
||||||
}
|
<TooltipContent>
|
||||||
/>
|
{user.OnlineStatus.split("_")
|
||||||
<TooltipContent>
|
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
{onlineStatus
|
.join(" ")}
|
||||||
.split("_")
|
</TooltipContent>
|
||||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
</Tooltip>
|
||||||
.join(" ")}
|
</div>
|
||||||
</TooltipContent>
|
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
||||||
</Tooltip>
|
<p>{user.Display}</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex w-full flex-col items-start justify-center gap-1 text-[15px]">
|
<div className="pr-1">{extra}</div>
|
||||||
<p>{display}</p>
|
</CardHeader>
|
||||||
</div>
|
</Card>
|
||||||
<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 "@methanium/ui";
|
import { Avatar, AvatarFallback, AvatarImage, Button } from "@tensamin/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="ghost"
|
variant="link"
|
||||||
aria-expanded={showAdvancedInformation}
|
aria-expanded={showAdvancedInformation}
|
||||||
onClick={() => setShowAdvancedInformation((show) => !show)}
|
onClick={() => setShowAdvancedInformation((show) => !show)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -4,29 +4,17 @@ import {
|
||||||
PopoverContent,
|
PopoverContent,
|
||||||
PopoverTrigger,
|
PopoverTrigger,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@methanium/ui";
|
} from "@tensamin/ui";
|
||||||
import {
|
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
|
||||||
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 "@methanium/ui";
|
import { Skeleton } from "@tensamin/ui";
|
||||||
import {
|
import { Select, SelectContent, SelectItem, SelectTrigger } from "@tensamin/ui";
|
||||||
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 "@methanium/ui";
|
import { SidebarTrigger, useSidebar } from "@tensamin/ui";
|
||||||
import { WindowControls as Controls } from "@methanium/ui";
|
import { WindowControls as Controls } from "@tensamin/ui";
|
||||||
import { useSession } from "@tensamin/storage/session";
|
import { useSession } from "@tensamin/storage/session";
|
||||||
import Profile from "./modals/profile";
|
import Profile from "./modals/profile";
|
||||||
|
|
||||||
|
|
@ -47,47 +35,37 @@ 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"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
|
className={`${forMobile && "border-b"} 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
|
||||||
render={({ onClick }) => (
|
className="w-9 h-9 aspect-square rounded-lg ml-2"
|
||||||
<Button
|
variant="outline"
|
||||||
onClick={onClick}
|
>
|
||||||
className="w-9 h-9! ml-2"
|
<ArrowLeft className="size-4.5" />
|
||||||
variant="ghost"
|
</SidebarTrigger>
|
||||||
>
|
|
||||||
<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}
|
||||||
|
|
@ -97,11 +75,10 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
||||||
) : (
|
) : (
|
||||||
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
|
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={({ onClick }) => (
|
render={
|
||||||
<Button
|
<Button
|
||||||
onClick={onClick}
|
|
||||||
variant="link"
|
variant="link"
|
||||||
className="text-foreground px-0! border-0! bg-none! bg-transparent!"
|
className="px-0! text-foreground"
|
||||||
style={{
|
style={{
|
||||||
textDecorationLine: "none",
|
textDecorationLine: "none",
|
||||||
}}
|
}}
|
||||||
|
|
@ -110,7 +87,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} />
|
||||||
|
|
@ -132,7 +109,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 />
|
||||||
|
|
@ -147,7 +124,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>
|
||||||
|
|
@ -184,11 +161,6 @@ 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>
|
||||||
|
|
@ -203,7 +175,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 border-0!"
|
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1"
|
||||||
variant="link"
|
variant="link"
|
||||||
>
|
>
|
||||||
<User className="size-4.5" />
|
<User className="size-4.5" />
|
||||||
|
|
@ -214,7 +186,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 border-0! bg-none! bg-transparent!"
|
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
|
||||||
variant="link"
|
variant="link"
|
||||||
>
|
>
|
||||||
<House className="size-4.5" />
|
<House className="size-4.5" />
|
||||||
|
|
@ -225,7 +197,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 border-0! bg-none! bg-transparent!"
|
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
|
||||||
variant="link"
|
variant="link"
|
||||||
>
|
>
|
||||||
<Settings className="size-4.5" />
|
<Settings className="size-4.5" />
|
||||||
|
|
|
||||||
|
|
@ -1,21 +1,13 @@
|
||||||
import { Button, cn, useIsMobile } from "@methanium/ui";
|
import { Button, cn, useIsMobile } from "@tensamin/ui";
|
||||||
import { Input } from "@methanium/ui";
|
import { Input } from "@tensamin/ui";
|
||||||
import { Label } from "@methanium/ui";
|
import { Label } from "@tensamin/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 {
|
import * as React from "react";
|
||||||
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(),
|
||||||
|
|
@ -85,13 +77,12 @@ function parseTuFileContent(rawFileContent: string): {
|
||||||
|
|
||||||
export default function Form() {
|
export default function Form() {
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
const uploadRef = React.useRef<HTMLInputElement | null>(null);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = React.useState(false);
|
||||||
const { save } = useStorage();
|
const { save } = useStorage();
|
||||||
const navigate = useNavigate();
|
const loginPendingRef = React.useRef(false);
|
||||||
const loginPendingRef = useRef(false);
|
|
||||||
|
|
||||||
const persistLogin = useCallback(
|
const persistLogin = React.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;
|
||||||
|
|
@ -100,17 +91,22 @@ 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);
|
||||||
await navigate({ to: "/", replace: true });
|
window.history.replaceState(
|
||||||
|
null,
|
||||||
|
"",
|
||||||
|
window.location.protocol === "file:" ? "#/" : "/",
|
||||||
|
);
|
||||||
|
window.location.reload();
|
||||||
return true;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
loginPendingRef.current = false;
|
loginPendingRef.current = false;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[navigate, save],
|
[save],
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process dropped files
|
// Process dropped files
|
||||||
const processDroppedFile = useCallback(
|
const processDroppedFile = React.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")) {
|
||||||
|
|
@ -131,8 +127,8 @@ export default function Form() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Handle .tu files
|
// Handle .tu files
|
||||||
const handleFileInputChange = useCallback(
|
const handleFileInputChange = React.useCallback(
|
||||||
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||||
const file = event.currentTarget.files?.[0];
|
const file = event.currentTarget.files?.[0];
|
||||||
|
|
||||||
if (!file) {
|
if (!file) {
|
||||||
|
|
@ -146,7 +142,7 @@ export default function Form() {
|
||||||
);
|
);
|
||||||
|
|
||||||
// Drag and drop listener
|
// Drag and drop listener
|
||||||
useEffect(() => {
|
React.useEffect(() => {
|
||||||
let dragCounter = 0;
|
let dragCounter = 0;
|
||||||
|
|
||||||
const handleDragEnter = (event: DragEvent) => {
|
const handleDragEnter = (event: DragEvent) => {
|
||||||
|
|
@ -216,8 +212,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 = useCallback(
|
const handleCredentialsSubmit = React.useCallback(
|
||||||
async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
async (event: React.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 "@methanium/ui";
|
} from "@tensamin/ui";
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
import { useIsMobile } from "@methanium/ui";
|
import { useIsMobile } from "@tensamin/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"
|
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"
|
||||||
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 { useRef, useState } from "react";
|
import * as React 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] = useState<"conversations" | "communities">(
|
const [category, setCategory] = React.useState<
|
||||||
"conversations",
|
"conversations" | "communities"
|
||||||
);
|
>("conversations");
|
||||||
|
|
||||||
const { contacts, communities } = useSession();
|
const { contacts, communities } = useSession();
|
||||||
const items = category === "conversations" ? contacts : communities;
|
const items = category === "conversations" ? contacts : communities;
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
const scrollRef = React.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 p-px"
|
className="overflow-y-auto flex-1 h-full"
|
||||||
>
|
>
|
||||||
{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 "@methanium/ui";
|
} from "@tensamin/ui";
|
||||||
import { useNavigate } from "@tanstack/react-router";
|
import { useNavigate } from "@tanstack/react-router";
|
||||||
import { useSidebar } from "@methanium/ui";
|
import { useSidebar } from "@tensamin/ui";
|
||||||
|
|
||||||
export default function ConversationModal({ userId }: { userId: number }) {
|
export default function ConversationModal({ userId }: { userId: number }) {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
|
||||||
247
apps/web/src/features/legal/screen.tsx
Normal file
247
apps/web/src/features/legal/screen.tsx
Normal file
|
|
@ -0,0 +1,247 @@
|
||||||
|
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 "@methanium/ui/index.css";
|
import "@tensamin/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 "@methanium/ui";
|
import { ErrorScreen, ThemeProvider, useTheme } from "@tensamin/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,11 +43,12 @@ 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 "@methanium/ui";
|
import { useIsMobile, Toaster, TooltipProvider } from "@tensamin/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");
|
||||||
|
|
@ -130,11 +131,6 @@ function ThemeStorageBridge() {
|
||||||
setThemeBorderRadius,
|
setThemeBorderRadius,
|
||||||
themeCustomCss,
|
themeCustomCss,
|
||||||
setThemeCustomCss,
|
setThemeCustomCss,
|
||||||
parentThemeId,
|
|
||||||
setParentThemeId,
|
|
||||||
applyThemePreset,
|
|
||||||
themeDesign,
|
|
||||||
setThemeDesign,
|
|
||||||
} = useTheme();
|
} = useTheme();
|
||||||
const loadedRef = useRef(false);
|
const loadedRef = useRef(false);
|
||||||
|
|
||||||
|
|
@ -149,8 +145,6 @@ 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,
|
||||||
|
|
@ -160,23 +154,18 @@ 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);
|
||||||
if (customCss !== "") setThemeCustomCss(customCss);
|
setThemeCustomCss(customCss);
|
||||||
setThemeDesign(design);
|
|
||||||
loadedRef.current = true;
|
loadedRef.current = true;
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
@ -186,12 +175,9 @@ function ThemeStorageBridge() {
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
load,
|
load,
|
||||||
applyThemePreset,
|
|
||||||
setThemeBorderRadius,
|
setThemeBorderRadius,
|
||||||
setThemeColor,
|
setThemeColor,
|
||||||
setThemeCustomCss,
|
setThemeCustomCss,
|
||||||
setParentThemeId,
|
|
||||||
setThemeDesign,
|
|
||||||
setThemePalette,
|
setThemePalette,
|
||||||
setThemePolarity,
|
setThemePolarity,
|
||||||
setThemePrimaryColor,
|
setThemePrimaryColor,
|
||||||
|
|
@ -226,14 +212,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -250,8 +228,6 @@ 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
|
||||||
|
|
@ -268,7 +244,13 @@ function RootShell() {
|
||||||
<Storage>
|
<Storage>
|
||||||
<ThemeStorageBridge />
|
<ThemeStorageBridge />
|
||||||
<LoginWrapper>
|
<LoginWrapper>
|
||||||
<Outlet />
|
<LegalWrapper>
|
||||||
|
<Crypto>
|
||||||
|
<DesktopMediaProvider>
|
||||||
|
<Outlet />
|
||||||
|
</DesktopMediaProvider>
|
||||||
|
</Crypto>
|
||||||
|
</LegalWrapper>
|
||||||
</LoginWrapper>
|
</LoginWrapper>
|
||||||
</Storage>
|
</Storage>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
|
|
@ -280,29 +262,24 @@ function RootShell() {
|
||||||
|
|
||||||
function AppShell() {
|
function AppShell() {
|
||||||
return (
|
return (
|
||||||
<OnboardingGate>
|
<MTPProvider>
|
||||||
<Crypto>
|
<CacheSync />
|
||||||
<DesktopMediaProvider>
|
<Session>
|
||||||
<MTPProvider>
|
<UserProvider>
|
||||||
<CacheSync />
|
<CallInit />
|
||||||
<Session>
|
<CallPopout />
|
||||||
<UserProvider>
|
<TAuthWrapper>
|
||||||
<CallInit />
|
<AppLayout>
|
||||||
<TAuthWrapper>
|
<ChatContext>
|
||||||
<AppLayout>
|
<NotificationsProvider>
|
||||||
<ChatContext>
|
<Outlet />
|
||||||
<NotificationsProvider>
|
</NotificationsProvider>
|
||||||
<Outlet />
|
</ChatContext>
|
||||||
</NotificationsProvider>
|
</AppLayout>
|
||||||
</ChatContext>
|
</TAuthWrapper>
|
||||||
</AppLayout>
|
</UserProvider>
|
||||||
</TAuthWrapper>
|
</Session>
|
||||||
</UserProvider>
|
</MTPProvider>
|
||||||
</Session>
|
|
||||||
</MTPProvider>
|
|
||||||
</DesktopMediaProvider>
|
|
||||||
</Crypto>
|
|
||||||
</OnboardingGate>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -330,7 +307,7 @@ function createCallTrayIcon(color: string, speaking: boolean) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function CallInit() {
|
function CallInit() {
|
||||||
const callInvitePopup = useInitializeCall();
|
useInitializeCall();
|
||||||
|
|
||||||
const { load } = useStorage();
|
const { load } = useStorage();
|
||||||
const {
|
const {
|
||||||
|
|
@ -393,7 +370,7 @@ function CallInit() {
|
||||||
});
|
});
|
||||||
}, [inCall, primaryColor, speaking]);
|
}, [inCall, primaryColor, speaking]);
|
||||||
|
|
||||||
return callInvitePopup;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootRoute = createRootRoute({
|
const rootRoute = createRootRoute({
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
Input,
|
Input,
|
||||||
Button,
|
Button,
|
||||||
useIsMobile,
|
useIsMobile,
|
||||||
} from "@methanium/ui";
|
} from "@tensamin/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.type === "ErrorNotFound" || data.data.UserId === 0) {
|
if (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", {
|
||||||
ChatPartnerId: user.data.UserId,
|
ChatPartnerName: result.data,
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
insertContact(user.data.UserId);
|
insertContact(user.data.UserId);
|
||||||
|
|
@ -131,12 +131,8 @@ function AddConversationButton() {
|
||||||
setOpen(value);
|
setOpen(value);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DialogTrigger
|
<DialogTrigger render={<Button>Add Conversation</Button>} />
|
||||||
render={({ onClick }) => (
|
<DialogContent>
|
||||||
<Button onClick={onClick}>Add Conversation</Button>
|
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<DialogContent showCloseButton={false}>
|
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle>New Conversation</DialogTitle>
|
<DialogTitle>New Conversation</DialogTitle>
|
||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
|
|
@ -164,13 +160,7 @@ 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
|
<DialogClose render={<Button variant="outline">Cancel</Button>} />
|
||||||
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,9 +3,8 @@ 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 "@methanium/ui";
|
import { useIsMobile, cn, SidebarProvider } from "@tensamin/ui";
|
||||||
|
|
||||||
import { isTauri } from "@tauri-apps/api/core";
|
import { isTauri } from "@tauri-apps/api/core";
|
||||||
|
|
||||||
|
|
@ -17,7 +16,6 @@ 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 "@methanium/ui";
|
import { CreateScreen } from "@tensamin/ui";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Executes Page.
|
* Executes Page.
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ 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));
|
||||||
|
|
@ -106,7 +105,7 @@ export default defineConfig({
|
||||||
? {
|
? {
|
||||||
protocol: "ws",
|
protocol: "ws",
|
||||||
host,
|
host,
|
||||||
clientPort: 3000,
|
port: 1421,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
watch: {
|
watch: {
|
||||||
|
|
@ -137,7 +136,6 @@ 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",
|
||||||
|
|
@ -154,9 +152,8 @@ 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, "../../mtp-type-maps/type-maps.yaml") }),
|
mtp({ typeMaps: resolve(appDir, "../../type-maps.yaml") }),
|
||||||
{
|
{
|
||||||
name: "workspace-realpath-resolution",
|
name: "workspace-realpath-resolution",
|
||||||
enforce: "post",
|
enforce: "post",
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,6 @@ 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));
|
||||||
|
|
||||||
|
|
@ -32,18 +28,10 @@ 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,7 +53,6 @@
|
||||||
libglvnd
|
libglvnd
|
||||||
libnotify
|
libnotify
|
||||||
libpulseaudio
|
libpulseaudio
|
||||||
libsecret
|
|
||||||
libuuid
|
libuuid
|
||||||
libxkbcommon
|
libxkbcommon
|
||||||
mesa
|
mesa
|
||||||
|
|
@ -105,8 +104,7 @@
|
||||||
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"
|
||||||
|
|
@ -174,7 +172,6 @@
|
||||||
libglvnd
|
libglvnd
|
||||||
libnotify
|
libnotify
|
||||||
libpulseaudio
|
libpulseaudio
|
||||||
libsecret
|
|
||||||
libuuid
|
libuuid
|
||||||
libxkbcommon
|
libxkbcommon
|
||||||
mesa
|
mesa
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
MIT License
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
Copyright (c) 2022 Paul Miller (https://paulmillr.com)
|
||||||
|
|
||||||
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 @@
|
||||||
MIT License
|
The MIT License (MIT)
|
||||||
|
|
||||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
Copyright (c) 2022 Paul Miller (https://paulmillr.com)
|
||||||
|
|
||||||
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,22 +0,0 @@
|
||||||
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,15 +31,6 @@ 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
|
||||||
|
|
@ -58,15 +49,6 @@ 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
|
||||||
|
|
@ -163,6 +145,26 @@ 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
|
||||||
|
|
@ -492,23 +494,13 @@ 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.41
|
## @tensamin/ui@0.0.39
|
||||||
|
|
||||||
- License: UNKNOWN
|
- License: UNKNOWN
|
||||||
- Included files: none found
|
- Included files: none found
|
||||||
- Folder: `licenses/@tensamin_ui@0.0.41`
|
- Folder: `licenses/@tensamin_ui@0.0.39`
|
||||||
- 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
|
||||||
|
|
@ -607,6 +599,15 @@ 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
|
||||||
|
|
@ -822,15 +823,6 @@ 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
|
||||||
|
|
@ -966,21 +958,12 @@ 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`
|
||||||
|
|
||||||
## motion@12.42.2
|
## mtp@0.1.0
|
||||||
|
|
||||||
- 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.2.0`
|
- Folder: `licenses/mtp@0.1.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
|
||||||
|
|
|
||||||
202
licenses/comlink@4.4.2/LICENSE
Normal file
202
licenses/comlink@4.4.2/LICENSE
Normal file
|
|
@ -0,0 +1,202 @@
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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.
|
|
||||||
|
|
@ -1,21 +0,0 @@
|
||||||
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-24T10:43:11.914Z",
|
"timestamp": "2026-07-03T19:50:00.640Z",
|
||||||
"tools": [
|
"tools": [
|
||||||
{
|
{
|
||||||
"vendor": "OpenAI",
|
"vendor": "OpenAI",
|
||||||
|
|
@ -117,37 +117,6 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"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",
|
||||||
|
|
@ -210,37 +179,6 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"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",
|
||||||
|
|
@ -578,6 +516,76 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
|
@ -1772,15 +1780,15 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"bomRef": "pkg:npm/%40tensamin/ui@0.0.41",
|
"bomRef": "pkg:npm/%40tensamin/ui@0.0.39",
|
||||||
"name": "@tensamin/ui",
|
"name": "@tensamin/ui",
|
||||||
"version": "0.0.41",
|
"version": "0.0.39",
|
||||||
"purl": "pkg:npm/%40tensamin/ui@0.0.41",
|
"purl": "pkg:npm/%40tensamin/ui@0.0.39",
|
||||||
"externalReferences": [],
|
"externalReferences": [],
|
||||||
"properties": [
|
"properties": [
|
||||||
{
|
{
|
||||||
"name": "local:licenseFolder",
|
"name": "local:licenseFolder",
|
||||||
"value": "licenses/@tensamin_ui@0.0.41"
|
"value": "licenses/@tensamin_ui@0.0.39"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "local:sourcePackageDir",
|
"name": "local:sourcePackageDir",
|
||||||
|
|
@ -1788,41 +1796,6 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"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",
|
||||||
|
|
@ -2168,6 +2141,37 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
|
@ -2921,37 +2925,6 @@
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"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",
|
||||||
|
|
@ -3424,47 +3397,16 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"type": "library",
|
"type": "library",
|
||||||
"bomRef": "pkg:npm/motion@12.42.2",
|
"bomRef": "pkg:npm/mtp@0.1.0",
|
||||||
"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.2.0",
|
"version": "0.1.0",
|
||||||
"purl": "pkg:npm/mtp@0.2.0",
|
"purl": "pkg:npm/mtp@0.1.0",
|
||||||
"description": "MTP TypeScript SDK",
|
"description": "MTP TypeScript SDK",
|
||||||
"externalReferences": [],
|
"externalReferences": [],
|
||||||
"properties": [
|
"properties": [
|
||||||
{
|
{
|
||||||
"name": "local:licenseFolder",
|
"name": "local:licenseFolder",
|
||||||
"value": "licenses/mtp@0.2.0"
|
"value": "licenses/mtp@0.1.0"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "local:sourcePackageDir",
|
"name": "local:sourcePackageDir",
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"generatedAt": "2026-07-24T10:43:11.913Z",
|
"generatedAt": "2026-07-03T19:50:00.637Z",
|
||||||
"packageCount": 145,
|
"packageCount": 143,
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "@babel/runtime",
|
"name": "@babel/runtime",
|
||||||
|
|
@ -41,19 +41,6 @@
|
||||||
"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",
|
||||||
|
|
@ -80,19 +67,6 @@
|
||||||
"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",
|
||||||
|
|
@ -223,6 +197,32 @@
|
||||||
"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.3.0",
|
"version": "1.2.1",
|
||||||
"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,28 +682,15 @@
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "@tensamin/ui",
|
"name": "@tensamin/ui",
|
||||||
"version": "0.0.41",
|
"version": "0.0.39",
|
||||||
"license": "UNKNOWN",
|
"license": "UNKNOWN",
|
||||||
"homepage": null,
|
"homepage": null,
|
||||||
"repository": null,
|
"repository": null,
|
||||||
"description": null,
|
"description": null,
|
||||||
"files": [],
|
"files": [],
|
||||||
"licenseFolder": "licenses/@tensamin_ui@0.0.41",
|
"licenseFolder": "licenses/@tensamin_ui@0.0.39",
|
||||||
"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",
|
||||||
|
|
@ -834,6 +821,19 @@
|
||||||
"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.2.1",
|
"version": "1.3.0",
|
||||||
"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,19 +1115,6 @@
|
||||||
"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",
|
||||||
|
|
@ -1306,28 +1293,15 @@
|
||||||
"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.2.0",
|
"version": "0.1.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.2.0",
|
"licenseFolder": "licenses/mtp@0.1.0",
|
||||||
"sourcePackageDir": "packages/mtp/node_modules/mtp"
|
"sourcePackageDir": "packages/mtp/node_modules/mtp"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
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 utils/scripts/lint-packages.ts",
|
"lint": "node scripts/lint-packages.ts",
|
||||||
"check": "fallow && pnpm run lint",
|
"check": "fallow && pnpm run lint",
|
||||||
"copy-licenses": "node utils/scripts/copy-licenses.ts",
|
"copy-licenses": "node scripts/copy-licenses.ts",
|
||||||
"copy-releases": "node utils/scripts/copy-releases.ts",
|
"copy-releases": "node 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 utils/scripts/build-packages.ts",
|
"build:packages": "node scripts/build-packages.ts",
|
||||||
"update:packages": "node utils/scripts/update-packages.ts",
|
"update:packages": "node scripts/update-packages.ts",
|
||||||
"dev": "cd apps/web && pnpm dev",
|
"dev:web": "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.dev"
|
"delete:mobile": "cd apps/tauri && nix develop ../..#tauri --command adb uninstall net.tensamin.client"
|
||||||
},
|
},
|
||||||
"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": {
|
||||||
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.2/methanium-ui.tgz",
|
"@tensamin/ui": "*",
|
||||||
"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,7 +175,12 @@ 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),
|
||||||
|
),
|
||||||
|
];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -205,7 +210,12 @@ 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),
|
||||||
|
),
|
||||||
|
];
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -231,7 +241,12 @@ 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,6 +18,7 @@ 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);
|
||||||
|
|
@ -142,7 +143,6 @@ 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,6 +195,23 @@ 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],
|
||||||
);
|
);
|
||||||
|
|
@ -242,28 +259,13 @@ export default function CacheSync() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.type === "MessageReactionLive") {
|
if (message.type === "MessageReactionLive") {
|
||||||
const partnerId = Number(data.ChatPartnerId);
|
reactionPartnersRef.current.set(
|
||||||
const sendTime = Number(data.SendTime);
|
Number(data.SendTime),
|
||||||
const senderId = Number(data.SenderId);
|
Number(data.ChatPartnerId),
|
||||||
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, secureCache],
|
[accountId, insertMessage, removeMessage, replaceMessage],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
|
||||||
|
|
@ -19,11 +19,12 @@
|
||||||
"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:*",
|
||||||
"@methanium/ui": "*",
|
"@tensamin/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,13 +5,11 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
useIsMobile,
|
} from "@tensamin/ui";
|
||||||
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 MediaShareButton from "./buttons/mediaShare";
|
import ScreenshareButton from "./buttons/screenshare";
|
||||||
import LeaveButton from "./buttons/leave";
|
import LeaveButton from "./buttons/leave";
|
||||||
import {
|
import {
|
||||||
setCallIsPopout,
|
setCallIsPopout,
|
||||||
|
|
@ -33,7 +31,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);
|
||||||
|
|
@ -79,45 +77,34 @@ export default function Actions() {
|
||||||
(state) => state.usersInFocusedViewHidden,
|
(state) => state.usersInFocusedViewHidden,
|
||||||
);
|
);
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="w-full flex justify-between items-center">
|
||||||
className={cn(
|
<div className="w-30 flex justify-start">
|
||||||
"w-full flex items-center",
|
{view === "focused" && (
|
||||||
isMobile ? "justify-center" : "justify-between",
|
<Tooltip>
|
||||||
)}
|
<TooltipTrigger
|
||||||
>
|
render={
|
||||||
{!isMobile && (
|
<Button
|
||||||
<div className="w-30 flex justify-start">
|
onClick={() =>
|
||||||
{view === "focused" && (
|
setUsersInFocusedViewHidden(!usersInFocusedViewHidden)
|
||||||
<Tooltip>
|
}
|
||||||
<TooltipTrigger
|
variant="link"
|
||||||
render={({ ref, onClick }) => (
|
className="w-11 h-11 p-0! ml-3 text-foreground"
|
||||||
<Button
|
>
|
||||||
ref={ref as React.Ref<HTMLButtonElement>}
|
{usersInFocusedViewHidden ? (
|
||||||
onClick={(event) => {
|
<ChevronUp className="size-6" />
|
||||||
onClick?.(event);
|
) : (
|
||||||
setUsersInFocusedViewHidden(!usersInFocusedViewHidden);
|
<ChevronDown className="size-6" />
|
||||||
}}
|
)}
|
||||||
variant="ghost"
|
</Button>
|
||||||
className="w-11 h-11! p-0! ml-3 text-foreground border-0!"
|
}
|
||||||
>
|
/>
|
||||||
{usersInFocusedViewHidden ? (
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
<ChevronUp className="size-6" />
|
Hide users
|
||||||
) : (
|
</TooltipContent>
|
||||||
<ChevronDown className="size-6" />
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
</Button>
|
</div>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<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
|
||||||
|
|
@ -132,10 +119,10 @@ export default function Actions() {
|
||||||
tooltip="Deafen"
|
tooltip="Deafen"
|
||||||
portalContainer={portalContainer}
|
portalContainer={portalContainer}
|
||||||
/>
|
/>
|
||||||
<MediaShareButton
|
<ScreenshareButton
|
||||||
className={sharedClasses}
|
className={sharedClasses}
|
||||||
iconSize={sharedIconSize}
|
iconSize={sharedIconSize}
|
||||||
tooltip="Share media"
|
tooltip="Screenshare"
|
||||||
/>
|
/>
|
||||||
<InviteButton
|
<InviteButton
|
||||||
className={sharedClasses}
|
className={sharedClasses}
|
||||||
|
|
@ -145,15 +132,11 @@ export default function Actions() {
|
||||||
{isWatchingFocusedStream && focusedParticipantId !== ownId ? (
|
{isWatchingFocusedStream && focusedParticipantId !== ownId ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={({ ref, onClick }) => (
|
render={
|
||||||
<Button
|
<Button
|
||||||
ref={ref}
|
|
||||||
className={sharedClasses}
|
className={sharedClasses}
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={(event) => {
|
onClick={() => stopWatchingFocusedStream()}
|
||||||
onClick?.(event);
|
|
||||||
stopWatchingFocusedStream();
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<ScreenShareOff
|
<ScreenShareOff
|
||||||
style={{
|
style={{
|
||||||
|
|
@ -162,7 +145,7 @@ export default function Actions() {
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
}
|
||||||
/>
|
/>
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
Stop watching
|
Stop watching
|
||||||
|
|
@ -178,58 +161,50 @@ export default function Actions() {
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
{!isMobile && (
|
<div className="w-30 flex justify-end">
|
||||||
<div className="w-30 flex justify-end gap-1.5">
|
<Tooltip>
|
||||||
<Tooltip>
|
<TooltipTrigger
|
||||||
<TooltipTrigger
|
render={
|
||||||
render={({ ref, onClick }) => (
|
<Button
|
||||||
<Button
|
onClick={() => setCallIsPopout(!callIsPopout)}
|
||||||
ref={ref}
|
variant="link"
|
||||||
onClick={(event) => {
|
className="w-11 h-11 p-0! text-foreground"
|
||||||
onClick?.(event);
|
>
|
||||||
setCallIsPopout(!callIsPopout);
|
{callIsPopout ? (
|
||||||
}}
|
<SquareArrowOutDownLeft className="size-6" />
|
||||||
variant="ghost"
|
) : (
|
||||||
className="w-11 h-11! p-0! text-foreground border-0!"
|
<SquareArrowOutUpRight className="size-6" />
|
||||||
>
|
)}
|
||||||
{callIsPopout ? (
|
</Button>
|
||||||
<SquareArrowOutDownLeft className="size-6" />
|
}
|
||||||
) : (
|
/>
|
||||||
<SquareArrowOutUpRight className="size-6" />
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
)}
|
Popout
|
||||||
</Button>
|
</TooltipContent>
|
||||||
)}
|
</Tooltip>
|
||||||
/>
|
<Tooltip>
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
<TooltipTrigger
|
||||||
Popout
|
render={
|
||||||
</TooltipContent>
|
<Button
|
||||||
</Tooltip>
|
onClick={() => {
|
||||||
<Tooltip>
|
void toggleFullscreen();
|
||||||
<TooltipTrigger
|
}}
|
||||||
render={({ ref, onClick }) => (
|
variant="link"
|
||||||
<Button
|
className="w-11 h-11 p-0! mr-3 text-foreground"
|
||||||
ref={ref}
|
>
|
||||||
onClick={(event) => {
|
{callIsFullscreen ? (
|
||||||
onClick?.(event);
|
<Minimize className="size-6" />
|
||||||
void toggleFullscreen();
|
) : (
|
||||||
}}
|
<Maximize className="size-6" />
|
||||||
variant="ghost"
|
)}
|
||||||
className="w-11 h-11! p-0! mr-3 text-foreground border-0!"
|
</Button>
|
||||||
>
|
}
|
||||||
{callIsFullscreen ? (
|
/>
|
||||||
<Minimize className="size-6" />
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
) : (
|
Fullscreen
|
||||||
<Maximize className="size-6" />
|
</TooltipContent>
|
||||||
)}
|
</Tooltip>
|
||||||
</Button>
|
</div>
|
||||||
)}
|
|
||||||
/>
|
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
|
||||||
Fullscreen
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,7 @@
|
||||||
import { Button } from "@methanium/ui";
|
import { Button } from "@tensamin/ui";
|
||||||
import { toggleDeaf, useCall } from "../../store";
|
import { toggleDeaf, useCall } from "../../store";
|
||||||
import { HeadphoneOff, Headphones } from "lucide-react";
|
import { HeadphoneOff, Headphones } from "lucide-react";
|
||||||
import {
|
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||||
type CallButtonProps,
|
|
||||||
iconScale,
|
|
||||||
withButtonTooltip,
|
|
||||||
} from "./tooltipButton";
|
|
||||||
|
|
||||||
export default function DeafButton({
|
export default function DeafButton({
|
||||||
className,
|
className,
|
||||||
|
|
@ -29,5 +25,5 @@ export default function DeafButton({
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return withButtonTooltip(button, tooltip, portalContainer);
|
return withTooltip(button, tooltip, portalContainer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@methanium/ui";
|
} from "@tensamin/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={({ ref, onClick }) => (
|
render={
|
||||||
<Button ref={ref} onClick={onClick} className={className}>
|
<Button 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,11 +1,7 @@
|
||||||
import { LeaveIcon } from "@livekit/components-react";
|
import { LeaveIcon } from "@livekit/components-react";
|
||||||
import { Button } from "@methanium/ui";
|
import { Button } from "@tensamin/ui";
|
||||||
import { disconnect, useCall } from "../../store";
|
import { disconnect, useCall } from "../../store";
|
||||||
import {
|
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||||
type CallButtonProps,
|
|
||||||
iconScale,
|
|
||||||
withButtonTooltip,
|
|
||||||
} from "./tooltipButton";
|
|
||||||
|
|
||||||
export default function LeaveButton({
|
export default function LeaveButton({
|
||||||
className,
|
className,
|
||||||
|
|
@ -26,5 +22,5 @@ export default function LeaveButton({
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return withButtonTooltip(button, tooltip, portalContainer);
|
return withTooltip(button, tooltip, portalContainer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,171 +0,0 @@
|
||||||
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,11 +1,7 @@
|
||||||
import { Button } from "@methanium/ui";
|
import { Button } from "@tensamin/ui";
|
||||||
import { toggleMute, useCall } from "../../store";
|
import { toggleMute, useCall } from "../../store";
|
||||||
import { Mic, MicOff } from "lucide-react";
|
import { Mic, MicOff } from "lucide-react";
|
||||||
import {
|
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||||
type CallButtonProps,
|
|
||||||
iconScale,
|
|
||||||
withButtonTooltip,
|
|
||||||
} from "./tooltipButton";
|
|
||||||
|
|
||||||
export default function MuteButton({
|
export default function MuteButton({
|
||||||
className,
|
className,
|
||||||
|
|
@ -29,5 +25,5 @@ export default function MuteButton({
|
||||||
</Button>
|
</Button>
|
||||||
);
|
);
|
||||||
|
|
||||||
return withButtonTooltip(button, tooltip, portalContainer);
|
return withTooltip(button, tooltip, portalContainer);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
169
packages/call/src/components/buttons/screenshare.tsx
Normal file
169
packages/call/src/components/buttons/screenshare.tsx
Normal file
|
|
@ -0,0 +1,169 @@
|
||||||
|
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,6 +1,5 @@
|
||||||
import { cloneElement } from "react";
|
import { type ReactElement } from "react";
|
||||||
import type { MouseEvent as ReactMouseEvent, ReactElement, Ref } from "react";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
|
|
||||||
|
|
||||||
export type CallButtonProps = {
|
export type CallButtonProps = {
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -13,31 +12,20 @@ export function iconScale(iconSize?: number) {
|
||||||
return { scale: (iconSize ? iconSize + 100 : 100) + "%" };
|
return { scale: (iconSize ? iconSize + 100 : 100) + "%" };
|
||||||
}
|
}
|
||||||
|
|
||||||
export function withButtonTooltip(
|
export function withTooltip(
|
||||||
trigger: ReactElement<{
|
button: ReactElement,
|
||||||
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
|
tooltip?: string,
|
||||||
ref?: Ref<HTMLElement>;
|
|
||||||
}>,
|
|
||||||
content?: string,
|
|
||||||
portalContainer?: HTMLElement,
|
portalContainer?: HTMLElement,
|
||||||
) {
|
) {
|
||||||
if (!content) return trigger;
|
if (!tooltip) {
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger render={button} />
|
||||||
render={({ ref, onClick }) =>
|
|
||||||
cloneElement(trigger, {
|
|
||||||
ref,
|
|
||||||
onClick: (event) => {
|
|
||||||
onClick?.(event);
|
|
||||||
trigger.props.onClick?.(event);
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||||
{content}
|
{tooltip}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import {
|
||||||
Button,
|
Button,
|
||||||
Dialog,
|
Dialog,
|
||||||
DialogContent,
|
DialogContent,
|
||||||
} from "@methanium/ui";
|
} from "@tensamin/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,10 +26,7 @@ export default function InvitePopup({
|
||||||
loading={null}
|
loading={null}
|
||||||
component={(user) => (
|
component={(user) => (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent
|
<DialogContent className="flex flex-col gap-5 items-center justify-center w-65 h-80">
|
||||||
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">
|
||||||
|
|
@ -39,14 +36,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-13 h-13!"
|
className="w-14 h-14"
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
onClick={() => onAccept(false)}
|
onClick={() => onAccept(false)}
|
||||||
>
|
>
|
||||||
<X className="size-5" />
|
<X className="size-5" />
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
className="w-13 h-13!"
|
className="w-14 h-14"
|
||||||
variant="subtleDefault"
|
variant="subtleDefault"
|
||||||
onClick={() => onAccept(true)}
|
onClick={() => onAccept(true)}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -1,223 +0,0 @@
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,77 +0,0 @@
|
||||||
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,8 +5,7 @@ import {
|
||||||
Button,
|
Button,
|
||||||
ContextMenu as UIContextMenu,
|
ContextMenu as UIContextMenu,
|
||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
cn,
|
} from "@tensamin/ui";
|
||||||
} from "@methanium/ui";
|
|
||||||
import {
|
import {
|
||||||
focusParticipant,
|
focusParticipant,
|
||||||
getRoomMetadata,
|
getRoomMetadata,
|
||||||
|
|
@ -44,7 +43,7 @@ function TransparentButton({ children }: { children: React.ReactNode }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAverageImageColor(src: string) {
|
function getAverageImageColor(src: string) {
|
||||||
return new Promise<string | undefined>((resolve) => {
|
return new Promise<string | undefined>((resolve) => {
|
||||||
const image = new Image();
|
const image = new Image();
|
||||||
|
|
||||||
|
|
@ -178,13 +177,6 @@ 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(() => {
|
||||||
|
|
@ -272,14 +264,9 @@ export default function Base({
|
||||||
render={
|
render={
|
||||||
<div
|
<div
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={cn(
|
className={`relative w-full ${fill ? "h-full border-0" : "aspect-video border-2"} ${
|
||||||
"relative w-full",
|
flush || fill ? "rounded-none" : "rounded-md"
|
||||||
fill ? "h-full" : "aspect-video",
|
} ${flush && !fill ? "border-x-0" : ""}`}
|
||||||
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">
|
||||||
<>
|
<>
|
||||||
|
|
@ -316,8 +303,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-lg"
|
flush ? "rounded-none" : "rounded-sm"
|
||||||
}`}
|
} ${type === "user" && isSpeaking ? "border-4 border-(--primary-foreground-alt)/75" : "border-0"}`}
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: avatarBackgroundColor,
|
backgroundColor: avatarBackgroundColor,
|
||||||
containerType: "size",
|
containerType: "size",
|
||||||
|
|
@ -348,31 +335,23 @@ export default function Base({
|
||||||
</div>
|
</div>
|
||||||
) : null)}
|
) : null)}
|
||||||
|
|
||||||
{type === "user" &&
|
{type === "user" && (
|
||||||
(cameraPublication?.track && !cameraDisabled ? (
|
<Avatar
|
||||||
<VideoViewer
|
style={{
|
||||||
fill={fill}
|
width: "32cqh",
|
||||||
flush={flush}
|
height: "32cqh",
|
||||||
participantId={participant.identity}
|
}}
|
||||||
publication={cameraPublication}
|
>
|
||||||
/>
|
<AvatarImage src={user.Avatar} />
|
||||||
) : (
|
<AvatarFallback
|
||||||
<Avatar
|
|
||||||
style={{
|
style={{
|
||||||
width: "32cqh",
|
fontSize: "11cqh",
|
||||||
height: "32cqh",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AvatarImage src={user.Avatar} />
|
{user.Display.slice(0, 2).toUpperCase()}
|
||||||
<AvatarFallback
|
</AvatarFallback>
|
||||||
style={{
|
</Avatar>
|
||||||
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 "@methanium/ui";
|
} from "@tensamin/ui";
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
import { setParticipantCameraDisabled, useCall } from "../../store";
|
import { useCall } from "../../store";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
|
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
|
||||||
|
|
@ -33,9 +33,6 @@ 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">
|
||||||
|
|
@ -74,19 +71,6 @@ 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,22 +1,10 @@
|
||||||
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, openCallPage, stopWatchingStream, useCall } from "../store";
|
import { getRoom, stopWatchingStream, useCall } from "../store";
|
||||||
import { useState, useRef, useEffect, useCallback } from "react";
|
import { useState, useRef, useEffect, useCallback } from "react";
|
||||||
import {
|
import { Button, cn } from "@tensamin/ui";
|
||||||
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,
|
||||||
|
|
@ -50,237 +38,6 @@ 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(
|
||||||
|
|
@ -698,10 +455,7 @@ 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,
|
||||||
);
|
);
|
||||||
|
|
@ -712,17 +466,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
336
packages/call/src/components/screenshareDialog.tsx
Normal file
336
packages/call/src/components/screenshareDialog.tsx
Normal file
|
|
@ -0,0 +1,336 @@
|
||||||
|
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 "@methanium/ui";
|
} from "@tensamin/ui";
|
||||||
import MediaShareButton from "./buttons/mediaShare";
|
import ScreenshareButton from "./buttons/screenshare";
|
||||||
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,36 +18,25 @@ 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";
|
||||||
|
|
||||||
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-start gap-1">
|
||||||
stat.state === "succeeded" &&
|
<MuteButton className="w-9 h-9" />
|
||||||
stat.currentRoundTripTime != null
|
<DeafButton className="w-9 h-9" />
|
||||||
) {
|
<ScreenshareButton className="w-9 h-9" defaultPortal />
|
||||||
bestRtt = stat.currentRoundTripTime * 1000;
|
<LeaveButton className="w-9 h-9" />
|
||||||
}
|
</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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function ConnectionBar() {
|
function ConnectionBar() {
|
||||||
|
|
@ -58,18 +47,14 @@ function ConnectionBar() {
|
||||||
return (
|
return (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger
|
<TooltipTrigger
|
||||||
render={({ ref, onClick }) => (
|
render={
|
||||||
<Button
|
<Button
|
||||||
ref={ref as React.Ref<HTMLButtonElement>}
|
onClick={() => openCallPage(callId || "")}
|
||||||
onClick={(event) => {
|
|
||||||
onClick?.(event);
|
|
||||||
openCallPage(callId || "");
|
|
||||||
}}
|
|
||||||
size="lg"
|
size="lg"
|
||||||
variant={
|
variant={
|
||||||
state === "open" && isEncrypted ? "subtleDefault" : "destructive"
|
state === "open" && isEncrypted ? "subtleDefault" : "destructive"
|
||||||
}
|
}
|
||||||
className="flex items-center justify-between px-2!"
|
className="flex justify-between items-center"
|
||||||
>
|
>
|
||||||
{state === "encrypting" && "Encrypting..."}
|
{state === "encrypting" && "Encrypting..."}
|
||||||
{state === "connecting" && "Connecting..."}
|
{state === "connecting" && "Connecting..."}
|
||||||
|
|
@ -85,7 +70,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>
|
||||||
|
|
@ -186,23 +171,34 @@ export function TinyPingGraph() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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-center gap-1">
|
stat.type === "candidate-pair" &&
|
||||||
<MuteButton className="h-9! w-[24%]" />
|
stat.state === "succeeded" &&
|
||||||
<DeafButton className="h-9! w-[24%]" />
|
stat.currentRoundTripTime != null
|
||||||
<MediaShareButton className="h-9! w-[24%]!" defaultPortal />
|
) {
|
||||||
<LeaveButton className="h-9! w-[24%]" />
|
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;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ import {
|
||||||
Tooltip,
|
Tooltip,
|
||||||
TooltipContent,
|
TooltipContent,
|
||||||
TooltipTrigger,
|
TooltipTrigger,
|
||||||
} from "@methanium/ui";
|
} from "@tensamin/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 "@methanium/ui";
|
import { cn } from "@tensamin/ui";
|
||||||
import { Loader2 } from "lucide-react";
|
import { Loader2 } from "lucide-react";
|
||||||
|
|
||||||
export default function VideoViewer({
|
export default function VideoViewer({
|
||||||
|
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,154 +0,0 @@
|
||||||
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 };
|
|
||||||
}
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
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 });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,27 +0,0 @@
|
||||||
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";
|
|
||||||
|
|
@ -1,270 +0,0 @@
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +0,0 @@
|
||||||
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,13 +31,8 @@ function copyDocumentStyles(targetDocument: Document) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncDocumentAttributes(targetDocument: Document) {
|
function syncDocumentClasses(targetDocument: Document) {
|
||||||
for (const attribute of document.documentElement.attributes) {
|
targetDocument.documentElement.className = document.documentElement.className;
|
||||||
targetDocument.documentElement.setAttribute(
|
|
||||||
attribute.name,
|
|
||||||
attribute.value,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
targetDocument.body.className = document.body.className;
|
targetDocument.body.className = document.body.className;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -63,12 +58,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%";
|
||||||
|
|
|
||||||
177
packages/call/src/screenshare.ts
Normal file
177
packages/call/src/screenshare.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
||||||
|
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,13 +2,11 @@ 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,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|
@ -22,19 +20,10 @@ export function clearSpeakingParticipants() {
|
||||||
|
|
||||||
export function removeSpeakingParticipant(participantId: number) {
|
export function removeSpeakingParticipant(participantId: number) {
|
||||||
useSpeakingState.setState((state) => {
|
useSpeakingState.setState((state) => {
|
||||||
const wasSpeaking = state.speakingParticipantIds.has(participantId);
|
if (!state.speakingParticipantIds.has(participantId)) return state;
|
||||||
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 {
|
return { speakingParticipantIds: next };
|
||||||
speakingParticipantIds: next,
|
|
||||||
lastSpeakingParticipantId: wasLastSpeaking
|
|
||||||
? null
|
|
||||||
: state.lastSpeakingParticipantId,
|
|
||||||
};
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -42,13 +31,11 @@ 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)) {
|
||||||
|
|
@ -57,16 +44,10 @@ export function updateSpeakingParticipants(changed: Map<number, boolean>) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return hasDiff
|
return hasDiff ? { speakingParticipantIds: next } : state;
|
||||||
? { 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,7 +4,6 @@ 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,
|
||||||
|
|
@ -26,16 +25,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 {
|
||||||
createMediaShareController,
|
createScreenShareController,
|
||||||
type LocalMediaShareSession,
|
type ScreenShareSession,
|
||||||
} from "./mediaShare/controller";
|
} from "./screenshare";
|
||||||
import type { MediaShareRequest } from "./mediaShare";
|
|
||||||
import {
|
import {
|
||||||
getSpeakingDetector,
|
getSpeakingDetector,
|
||||||
disposeSpeakingDetector,
|
disposeSpeakingDetector,
|
||||||
|
|
@ -101,11 +100,8 @@ type CallStore = {
|
||||||
currentCallData: CurrentCallData;
|
currentCallData: CurrentCallData;
|
||||||
deaf: boolean;
|
deaf: boolean;
|
||||||
micEnabled: boolean;
|
micEnabled: boolean;
|
||||||
cameraEnabled: boolean;
|
|
||||||
screenShareEnabled: boolean;
|
screenShareEnabled: boolean;
|
||||||
screenShareSession: LocalMediaShareSession | null;
|
screenShareSession: ScreenShareSession | null;
|
||||||
cameraSession: LocalMediaShareSession | null;
|
|
||||||
disabledCameraParticipantIds: number[];
|
|
||||||
focusedParticipantId: number | null;
|
focusedParticipantId: number | null;
|
||||||
focusedParticipantType: "user" | "stream" | null;
|
focusedParticipantType: "user" | "stream" | null;
|
||||||
usersInFocusedViewHidden: boolean;
|
usersInFocusedViewHidden: boolean;
|
||||||
|
|
@ -158,8 +154,6 @@ 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;
|
||||||
|
|
@ -167,31 +161,6 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
@ -304,21 +273,11 @@ 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)) {
|
||||||
const subscribed =
|
publication.setSubscribed(
|
||||||
publication.source === Track.Source.Camera
|
publication.kind === Track.Kind.Audio &&
|
||||||
? !cameraDisabled
|
publication.source !== Track.Source.ScreenShareAudio,
|
||||||
: publication.source === Track.Source.ScreenShare ||
|
);
|
||||||
publication.source === Track.Source.ScreenShareAudio
|
|
||||||
? watchesScreen
|
|
||||||
: publication.kind === Track.Kind.Audio;
|
|
||||||
publication.setSubscribed(subscribed);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -608,13 +567,11 @@ 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 { cameraSession, screenShareSession } = useCall.getState();
|
const { 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:
|
||||||
|
|
@ -738,16 +695,6 @@ 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);
|
||||||
|
|
@ -783,20 +730,6 @@ 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,
|
||||||
|
|
@ -812,17 +745,6 @@ 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,
|
||||||
|
|
@ -863,8 +785,9 @@ export function stopWatchingFocusedStream() {
|
||||||
stopWatchingStream(focusedParticipantId);
|
stopWatchingStream(focusedParticipantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
let mediaShareController: ReturnType<typeof createMediaShareController> | null =
|
let screenShareController: ReturnType<
|
||||||
null;
|
typeof createScreenShareController
|
||||||
|
> | null = null;
|
||||||
|
|
||||||
function getNoiseFilterAssetBaseUrl() {
|
function getNoiseFilterAssetBaseUrl() {
|
||||||
if (window.location.protocol === "file:") {
|
if (window.location.protocol === "file:") {
|
||||||
|
|
@ -874,21 +797,17 @@ function getNoiseFilterAssetBaseUrl() {
|
||||||
return "/assets";
|
return "/assets";
|
||||||
}
|
}
|
||||||
|
|
||||||
function getMediaShareController() {
|
function getScreenShareController() {
|
||||||
if (!mediaShareController) {
|
if (!screenShareController) {
|
||||||
mediaShareController = createMediaShareController({
|
screenShareController = createScreenShareController({
|
||||||
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({
|
? updater({ screenShareSession: state.screenShareSession })
|
||||||
screenShareSession: state.screenShareSession,
|
|
||||||
cameraSession: state.cameraSession,
|
|
||||||
})
|
|
||||||
: updater,
|
: updater,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
@ -900,7 +819,7 @@ function getMediaShareController() {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return mediaShareController;
|
return screenShareController;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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.
|
||||||
|
|
@ -947,12 +866,11 @@ 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 getMediaShareController().clearAll();
|
await getScreenShareController().clearPublishedScreenShare();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log(
|
log(
|
||||||
1,
|
1,
|
||||||
|
|
@ -974,9 +892,6 @@ 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,
|
||||||
|
|
@ -1024,10 +939,6 @@ 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) {
|
||||||
|
|
@ -1104,37 +1015,37 @@ export async function toggleMute() {
|
||||||
syncParticipantState();
|
syncParticipantState();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startScreenShare(
|
// Start browser-native screen sharing for the current participant.
|
||||||
request: Omit<MediaShareRequest, "kind"> = {},
|
export async function startScreenShare(options?: ScreenShareCaptureOptions) {
|
||||||
) {
|
await getScreenShareController().startScreenShare(options);
|
||||||
await getMediaShareController().start({ ...request, kind: "screen" });
|
|
||||||
await publishScreenSharePreview();
|
await publishScreenSharePreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function startCameraShare(sourceId?: string) {
|
// Start the Linux desktop capture path that renders frames through Tauri.
|
||||||
await getMediaShareController().start({ kind: "camera", sourceId });
|
export async function startLinuxDesktopScreenShare(sourceId: string) {
|
||||||
|
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 getMediaShareController().stop("screen");
|
await getScreenShareController().stopScreenShare();
|
||||||
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,
|
||||||
request: Omit<MediaShareRequest, "kind"> = {},
|
options?: ScreenShareCaptureOptions,
|
||||||
) {
|
) {
|
||||||
|
await getScreenShareController().setScreenShareEnabled(enabled, options);
|
||||||
|
|
||||||
if (enabled) {
|
if (enabled) {
|
||||||
await startScreenShare(request);
|
await publishScreenSharePreview();
|
||||||
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.
|
||||||
|
|
@ -1149,10 +1060,7 @@ 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,
|
||||||
|
|
@ -1206,11 +1114,8 @@ 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,
|
||||||
|
|
@ -1269,14 +1174,12 @@ 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 });
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -1285,7 +1188,6 @@ 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) {
|
||||||
|
|
@ -1333,11 +1235,6 @@ 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),
|
||||||
|
|
@ -1406,7 +1303,6 @@ 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();
|
||||||
|
|
@ -1480,8 +1376,6 @@ 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", {
|
||||||
|
|
@ -1491,14 +1385,11 @@ 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) {
|
||||||
|
|
@ -1525,10 +1416,6 @@ 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 &&
|
||||||
|
|
@ -1550,10 +1437,6 @@ 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
|
||||||
|
|
@ -1570,22 +1453,17 @@ 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) {
|
||||||
syncRemoteParticipantTrackSubscriptions(participantId);
|
if (
|
||||||
}
|
publication.kind === Track.Kind.Audio &&
|
||||||
|
publication.source !== Track.Source.ScreenShareAudio
|
||||||
onParticipantStateChange();
|
) {
|
||||||
};
|
publication.setSubscribed(true);
|
||||||
|
} else {
|
||||||
const onTrackUnpublished = (publication: RemoteTrackPublication) => {
|
publication.setSubscribed(false);
|
||||||
if (publication.source === Track.Source.ScreenShare) {
|
}
|
||||||
playSound("stream_end_other");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onParticipantStateChange();
|
onParticipantStateChange();
|
||||||
|
|
@ -1648,7 +1526,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, onTrackUnpublished);
|
room.on(RoomEvent.TrackUnpublished, onParticipantStateChange);
|
||||||
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);
|
||||||
|
|
@ -1669,7 +1547,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, onTrackUnpublished);
|
room.off(RoomEvent.TrackUnpublished, onParticipantStateChange);
|
||||||
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);
|
||||||
|
|
@ -1707,7 +1585,7 @@ export function useInitializeCall() {
|
||||||
}
|
}
|
||||||
|
|
||||||
setCurrentCallData({
|
setCurrentCallData({
|
||||||
...mtp.CallData.response.parse(data.data),
|
...(data.data as z.infer<typeof mtp.CallData.response>),
|
||||||
exists: true,
|
exists: true,
|
||||||
});
|
});
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
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 { getRoom, setUsersInFocusedViewHidden, useCall } from "../../store";
|
import { useCall, getRoom } 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);
|
||||||
|
|
||||||
|
|
@ -29,10 +27,6 @@ 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,7 +2,6 @@ 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;
|
||||||
|
|
@ -215,8 +214,6 @@ 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}
|
||||||
|
|
@ -224,7 +221,7 @@ export default function View() {
|
||||||
height: "calc(100% - 2rem)",
|
height: "calc(100% - 2rem)",
|
||||||
width: "calc(100% - 2rem)",
|
width: "calc(100% - 2rem)",
|
||||||
}}
|
}}
|
||||||
className={cn("overflow-hidden", isMobile ? "" : "p-3")}
|
className="overflow-hidden 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,7 +7,6 @@ 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);
|
||||||
|
|
@ -130,8 +129,6 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
setIsImmersiveChromeVisible(false);
|
setIsImmersiveChromeVisible(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
ref={screenRef}
|
ref={screenRef}
|
||||||
|
|
@ -142,25 +139,23 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
||||||
onMouseMove={showImmersiveChrome}
|
onMouseMove={showImmersiveChrome}
|
||||||
onMouseLeave={hideImmersiveChrome}
|
onMouseLeave={hideImmersiveChrome}
|
||||||
>
|
>
|
||||||
{!isMobile && (
|
<div
|
||||||
<div
|
ref={topBarRef}
|
||||||
ref={topBarRef}
|
className={`shrink-0 w-full z-40 transition-all duration-200 ${
|
||||||
className={`shrink-0 w-full z-40 transition-all duration-200 ${
|
isImmersiveFocusedView
|
||||||
isImmersiveFocusedView
|
? immersiveChromeVisible
|
||||||
? immersiveChromeVisible
|
? "opacity-100 translate-y-0 pointer-events-auto"
|
||||||
? "opacity-100 translate-y-0 pointer-events-auto"
|
: "opacity-0 -translate-y-2 pointer-events-none"
|
||||||
: "opacity-0 -translate-y-2 pointer-events-none"
|
: "opacity-100 translate-y-0"
|
||||||
: "opacity-100 translate-y-0"
|
}`}
|
||||||
}`}
|
style={{
|
||||||
style={{
|
position: usersInFocusedViewHidden ? "absolute" : "relative",
|
||||||
position: usersInFocusedViewHidden ? "absolute" : "relative",
|
top: 0,
|
||||||
top: 0,
|
left: 0,
|
||||||
left: 0,
|
}}
|
||||||
}}
|
>
|
||||||
>
|
<TopBar />
|
||||||
<TopBar />
|
</div>
|
||||||
</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,5 +1,6 @@
|
||||||
- Overlay for stream modals
|
- Overlay for stream modals
|
||||||
- Mobile
|
- Mobile
|
||||||
|
- Sounds
|
||||||
- Admin call actions
|
- Admin call actions
|
||||||
- Timeout
|
- Timeout
|
||||||
- Disconnect
|
- Disconnect
|
||||||
|
|
@ -7,4 +8,3 @@
|
||||||
- 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:*",
|
||||||
"@methanium/ui": "*",
|
"@tensamin/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 "@methanium/ui";
|
import { Button } from "@tensamin/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 "@methanium/ui";
|
} from "@tensamin/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,27 +1,32 @@
|
||||||
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,
|
||||||
} from "@methanium/ui";
|
Skeleton,
|
||||||
|
} 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 "@methanium/ui";
|
import { Button } from "@tensamin/ui";
|
||||||
|
|
||||||
import { Plus, Laugh, FileVideo } from "lucide-react";
|
import { Plus, Laugh, FileVideo, Forward, X } 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 "@methanium/ui";
|
import { cn, useIsMobile } from "@tensamin/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 ReplyBox from "./replyBox";
|
import Wrapper from "@tensamin/user/wrapper";
|
||||||
|
import Text from "@tensamin/markdown/text";
|
||||||
|
|
||||||
export default function InputComponent({
|
export default function InputComponent({
|
||||||
value,
|
value,
|
||||||
|
|
@ -112,7 +117,6 @@ 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,
|
||||||
|
|
@ -192,15 +196,54 @@ export default function InputComponent({
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col">
|
<div className="flex flex-col">
|
||||||
{/* reply */}
|
|
||||||
{replyTo !== undefined && (
|
{replyTo !== undefined && (
|
||||||
<ReplyBox
|
<div className="self-center rounded-t-lg bg-background border-t border-x flex items-center justify-start w-[90%] p-1">
|
||||||
content={replyMessage?.Content}
|
<div className="flex gap-1 w-full items-center">
|
||||||
loading={!replyMessage}
|
<Forward
|
||||||
onDismiss={() => setReplyTo(undefined)}
|
color="var(--muted-foreground)"
|
||||||
userId={replyUserId ?? ownId}
|
className="opacity-60"
|
||||||
variant="composer"
|
size={18}
|
||||||
/>
|
/>
|
||||||
|
{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}
|
||||||
|
|
@ -224,7 +267,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">
|
||||||
|
|
@ -237,16 +280,15 @@ export default function InputComponent({
|
||||||
onOpenChange={setEmojiPopoverOpen}
|
onOpenChange={setEmojiPopoverOpen}
|
||||||
>
|
>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={({ onClick }) => (
|
render={
|
||||||
<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
|
||||||
|
|
@ -260,15 +302,11 @@ export default function InputComponent({
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={({ onClick }) => (
|
render={
|
||||||
<Button
|
<Button className="w-9 h-9 p-0" variant="ghost">
|
||||||
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 "@methanium/ui";
|
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/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 "@methanium/ui";
|
import { Button, cn } from "@tensamin/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,7 +1,8 @@
|
||||||
|
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, CheckLine, RefreshCw } from "lucide-react";
|
import { AlertTriangle, Check, Ellipse, RefreshCw } from "lucide-react";
|
||||||
import { memo, useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import type { User } from "@tensamin/user/context";
|
import type { User } from "@tensamin/user/context";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
|
@ -11,19 +12,17 @@ import {
|
||||||
Button,
|
Button,
|
||||||
cn,
|
cn,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
} from "@methanium/ui";
|
} from "@tensamin/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 { getMessage, useChat } from "../context";
|
import { useChat } from "../context";
|
||||||
import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
|
import { 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,
|
||||||
|
|
@ -143,45 +142,7 @@ 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);
|
||||||
|
|
@ -272,16 +233,8 @@ 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 flex-col gap-1 justify-start items-start transition-opacity duration-150 ${opacityClass}`}
|
className={`${grouped ? "" : "pt-3"} w-full flex justify-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}
|
||||||
|
|
@ -291,149 +244,138 @@ function MessageComponent({
|
||||||
onReact={toggleReaction}
|
onReact={toggleReaction}
|
||||||
onSetEditing={setEditing}
|
onSetEditing={setEditing}
|
||||||
>
|
>
|
||||||
<>
|
<div
|
||||||
<div
|
className={cn(
|
||||||
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",
|
||||||
"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":
|
||||||
"bg-(--destructive)/10 text-destructive-foreground hover:bg-(--destructive)/15":
|
actuallyFailed,
|
||||||
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([], {
|
||||||
{grouped ? (
|
hour: "2-digit",
|
||||||
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
minute: "2-digit",
|
||||||
{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,
|
|
||||||
})}
|
})}
|
||||||
>
|
</p>
|
||||||
{!grouped && (
|
) : (
|
||||||
<div className="flex items-center gap-1">
|
<Avatar className="mr-1 mb-auto mt-1 w-10">
|
||||||
<p className="font-semibold">{user.Display}</p>
|
<AvatarImage src={user.Avatar} />
|
||||||
<p className="text-xs text-muted-foreground">
|
<AvatarFallback>
|
||||||
{new Date(message.SendTime).toLocaleString([], {
|
{user.Display.slice(0, 2).toUpperCase()}
|
||||||
hour: "2-digit",
|
</AvatarFallback>
|
||||||
minute: "2-digit",
|
</Avatar>
|
||||||
})}
|
)}
|
||||||
</p>
|
<div
|
||||||
<div className="flex items-center justify-start gap-1">
|
className={cn("flex flex-col group", {
|
||||||
{message.SenderId !==
|
"min-w-0 flex-1": editing,
|
||||||
ownId ? null : message.MessageState === "read" ? (
|
})}
|
||||||
<Check
|
>
|
||||||
size={12}
|
{!grouped && (
|
||||||
color="var(--primary-foreground-alt)"
|
<div className="flex items-center gap-1">
|
||||||
/>
|
<p className="font-medium">{user.Display}</p>
|
||||||
) : message.MessageState === "received" ? (
|
<p className="text-xs text-muted-foreground">
|
||||||
<CheckLine
|
{new Date(message.SendTime).toLocaleString([], {
|
||||||
size={12}
|
hour: "2-digit",
|
||||||
color="var(--muted-foreground)"
|
minute: "2-digit",
|
||||||
/>
|
})}
|
||||||
) : message.MessageState === "sent" ? (
|
</p>
|
||||||
<Check size={12} color="var(--muted-foreground)" />
|
<div className="flex items-center justify-start gap-1">
|
||||||
) : message.MessageState === "sending" ? (
|
{message.MessageState === "read" ? (
|
||||||
<RefreshCw
|
<Check
|
||||||
size={12}
|
size={12}
|
||||||
color="var(--muted-foreground)"
|
color="var(--primary-foreground-alt)"
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : message.MessageState === "received" ? (
|
||||||
{actuallyFailed && (
|
<Check size={12} color="var(--muted-foreground)" />
|
||||||
<AlertTriangle color="var(--destructive)" size={12} />
|
) : message.MessageState === "sent" ? (
|
||||||
)}
|
<RefreshCw size={12} color="var(--muted-foreground)" />
|
||||||
</div>
|
) : message.MessageState === "sending" ? (
|
||||||
</div>
|
<Ellipse size={12} color="var(--muted-foreground)" />
|
||||||
)}
|
) : null}
|
||||||
{editing ? (
|
{actuallyFailed && (
|
||||||
<div className="flex w-full flex-col gap-1">
|
<AlertTriangle color="var(--destructive)" size={12} />
|
||||||
<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>
|
<div className="flex w-full flex-col">
|
||||||
</>
|
<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" />
|
||||||
|
|
@ -442,12 +384,11 @@ function MessageComponent({
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export default memo(MessageComponent, (prev, next) => {
|
export default React.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 "@methanium/ui";
|
} from "@tensamin/ui";
|
||||||
import {
|
import {
|
||||||
Clipboard,
|
Clipboard,
|
||||||
Ellipsis,
|
Ellipsis,
|
||||||
|
|
@ -35,13 +35,11 @@ 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";
|
||||||
|
|
@ -262,32 +260,18 @@ 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
|
<TooltipTrigger render={children} />
|
||||||
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,
|
||||||
|
|
@ -299,7 +283,6 @@ function MiniMessageMenu({
|
||||||
onReply,
|
onReply,
|
||||||
shiftIsPressed,
|
shiftIsPressed,
|
||||||
}: {
|
}: {
|
||||||
fadeOut: boolean;
|
|
||||||
isOwnMessage: boolean;
|
isOwnMessage: boolean;
|
||||||
onDelete: () => void;
|
onDelete: () => void;
|
||||||
onEdit: () => void;
|
onEdit: () => void;
|
||||||
|
|
@ -312,12 +295,8 @@ function MiniMessageMenu({
|
||||||
shiftIsPressed: boolean;
|
shiftIsPressed: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<motion.div
|
<div className="absolute right-4 -top-3 z-10">
|
||||||
className="absolute right-4 -top-3 z-10"
|
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-0! shadow-lg">
|
||||||
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}
|
||||||
|
|
@ -340,16 +319,15 @@ function MiniMessageMenu({
|
||||||
children={
|
children={
|
||||||
usePickerTrigger ? (
|
usePickerTrigger ? (
|
||||||
<PopoverTrigger
|
<PopoverTrigger
|
||||||
render={({ onClick }) => (
|
render={
|
||||||
<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
|
||||||
|
|
@ -419,7 +397,7 @@ function MiniMessageMenu({
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
</motion.div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -560,25 +538,16 @@ 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(
|
||||||
() =>
|
() =>
|
||||||
|
|
@ -609,28 +578,20 @@ export default function MessageContextMenu({
|
||||||
onPointerLeave={scheduleMiniMenuClose}
|
onPointerLeave={scheduleMiniMenuClose}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
<AnimatePresence onExitComplete={() => setFadeMiniMenu(false)}>
|
{!isMobile && !hideMiniMenu && activeMenuId === messageId && (
|
||||||
{!isMobile &&
|
<MiniMessageMenu
|
||||||
!hideMiniMenu &&
|
isOwnMessage={isOwnMessage}
|
||||||
(activeMenuId === messageId ||
|
onDelete={() => deleteMessage(messageId)}
|
||||||
contextMenuOpen ||
|
onEdit={() => onSetEditing(true)}
|
||||||
pickerOpen ||
|
onOpenMenu={openMenu}
|
||||||
pickerClosing) && (
|
onOpenPicker={() => setPickerOpen(true)}
|
||||||
<MiniMessageMenu
|
usePickerTrigger={!isMobile}
|
||||||
fadeOut={fadeMiniMenu}
|
onReact={selectReaction}
|
||||||
isOwnMessage={isOwnMessage}
|
quickReactions={quickReactions}
|
||||||
onDelete={() => deleteMessage(messageId)}
|
onReply={() => setReplyTo(messageId)}
|
||||||
onEdit={() => onSetEditing(true)}
|
shiftIsPressed={shiftIsPressed}
|
||||||
onOpenMenu={openMenu}
|
/>
|
||||||
onOpenPicker={() => setPickerVisibility(true)}
|
)}
|
||||||
usePickerTrigger={!isMobile}
|
|
||||||
onReact={selectReaction}
|
|
||||||
quickReactions={quickReactions}
|
|
||||||
onReply={() => setReplyTo(messageId)}
|
|
||||||
shiftIsPressed={shiftIsPressed}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</AnimatePresence>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -675,14 +636,14 @@ export default function MessageContextMenu({
|
||||||
emojis={menuReactions}
|
emojis={menuReactions}
|
||||||
onMore={() => {
|
onMore={() => {
|
||||||
setReactionDrawerOpen(false);
|
setReactionDrawerOpen(false);
|
||||||
setPickerVisibility(true);
|
setPickerOpen(true);
|
||||||
}}
|
}}
|
||||||
onSelect={selectReaction}
|
onSelect={selectReaction}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</DrawerContent>
|
</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
<Drawer open={pickerOpen} onOpenChange={setPickerVisibility}>
|
<Drawer open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
<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">
|
||||||
|
|
@ -711,17 +672,8 @@ export default function MessageContextMenu({
|
||||||
});
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||||
open={pickerOpen}
|
<ContextMenu>
|
||||||
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}
|
||||||
|
|
@ -729,7 +681,7 @@ export default function MessageContextMenu({
|
||||||
devEnabled={devEnabled}
|
devEnabled={devEnabled}
|
||||||
isOwnMessage={isOwnMessage}
|
isOwnMessage={isOwnMessage}
|
||||||
messageId={messageId}
|
messageId={messageId}
|
||||||
onAddReaction={() => setPickerVisibility(true)}
|
onAddReaction={() => setPickerOpen(true)}
|
||||||
onReact={selectReaction}
|
onReact={selectReaction}
|
||||||
reactionEmojis={menuReactions}
|
reactionEmojis={menuReactions}
|
||||||
onSetEditing={onSetEditing}
|
onSetEditing={onSetEditing}
|
||||||
|
|
|
||||||
|
|
@ -1,97 +0,0 @@
|
||||||
import {
|
|
||||||
Avatar,
|
|
||||||
AvatarFallback,
|
|
||||||
AvatarImage,
|
|
||||||
Button,
|
|
||||||
cn,
|
|
||||||
Skeleton,
|
|
||||||
} from "@methanium/ui";
|
|
||||||
import Text from "@tensamin/markdown/text";
|
|
||||||
import type { User } from "@tensamin/user/context";
|
|
||||||
import Wrapper from "@tensamin/user/wrapper";
|
|
||||||
import { Forward, X } from "lucide-react";
|
|
||||||
|
|
||||||
function ReplyUser({ user }: { user: User }) {
|
|
||||||
return (
|
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<Avatar className="h-5 w-5 shrink-0">
|
|
||||||
<AvatarImage src={user.Avatar} />
|
|
||||||
<AvatarFallback className="text-[0.6rem]">
|
|
||||||
{user.Display.slice(0, 2).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<p className="text-sm font-semibold text-muted-foreground">
|
|
||||||
{user.Display}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function ReplyBox({
|
|
||||||
content,
|
|
||||||
loading = false,
|
|
||||||
onDismiss,
|
|
||||||
user,
|
|
||||||
userId,
|
|
||||||
variant,
|
|
||||||
}: {
|
|
||||||
content?: string;
|
|
||||||
loading?: boolean;
|
|
||||||
onDismiss?: () => void;
|
|
||||||
user?: User;
|
|
||||||
userId?: number;
|
|
||||||
variant: "composer" | "message";
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
variant === "composer" ? "flex justify-center" : "pl-5 w-full",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex w-[90%] items-center gap-1 text-xs",
|
|
||||||
variant === "composer"
|
|
||||||
? "justify-start rounded-t-lg border-x border-t border-(--border)/65 bg-background p-1"
|
|
||||||
: "text-muted-foreground",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Forward className="shrink-0 opacity-60" size={18} />
|
|
||||||
{loading ? (
|
|
||||||
<>
|
|
||||||
<Skeleton className="h-5 w-5 shrink-0 rounded-full" />
|
|
||||||
<Skeleton className="h-5 w-full" />
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
{user ? (
|
|
||||||
<ReplyUser user={user} />
|
|
||||||
) : userId ? (
|
|
||||||
<Wrapper
|
|
||||||
userId={userId}
|
|
||||||
loading={<Skeleton className="h-5 w-5 rounded-full" />}
|
|
||||||
component={(resolvedUser) => <ReplyUser user={resolvedUser} />}
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{content !== undefined && (
|
|
||||||
<div className="h-5.5 min-w-0 flex-1 truncate">
|
|
||||||
<Text fontSize="0.88rem" value={content} />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
{onDismiss && (
|
|
||||||
<Button
|
|
||||||
aria-label="Cancel reply"
|
|
||||||
className="ml-auto h-5 w-5 rounded-md! p-0!"
|
|
||||||
onClick={onDismiss}
|
|
||||||
size="xs"
|
|
||||||
variant="ghost"
|
|
||||||
>
|
|
||||||
<X className="opacity-60" size={18} />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue