(feat): big call and chatting stuff #23
153 changed files with 11309 additions and 5940 deletions
|
|
@ -18,6 +18,9 @@ jobs:
|
|||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -42,6 +45,9 @@ jobs:
|
|||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -128,6 +134,9 @@ jobs:
|
|||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -180,6 +189,9 @@ jobs:
|
|||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ jobs:
|
|||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -43,6 +46,9 @@ jobs:
|
|||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#tauri --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -129,6 +135,9 @@ jobs:
|
|||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
@ -177,6 +186,9 @@ jobs:
|
|||
- name: Check out repo
|
||||
uses: https://data.forgejo.org/actions/checkout@v4
|
||||
|
||||
- name: Pull git submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install dependencies
|
||||
run: nix develop .#electron --command pnpm install --frozen-lockfile
|
||||
|
||||
|
|
|
|||
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -2,3 +2,5 @@ node_modules
|
|||
releases
|
||||
.fallow
|
||||
.direnv
|
||||
keystore.jks
|
||||
keystore.properties
|
||||
|
|
|
|||
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[submodule "mtp-type-maps"]
|
||||
path = mtp-type-maps
|
||||
url = https://git.methanium.net/tensamin/mtp-type-maps
|
||||
25
LICENSE
25
LICENSE
|
|
@ -1,16 +1,15 @@
|
|||
Copyright (c) [2025] [Methanium]
|
||||
Copyright (c) 2025 Methanium
|
||||
|
||||
All rights reserved.
|
||||
|
||||
This software is protected by copyright. Copying, editing,
|
||||
distributing, publicly performing, or any other use of this software
|
||||
or its components, in source or binary form, is strictly prohibited without the express
|
||||
written permission of the copyright holder.
|
||||
No part of this software, source code, documentation, or
|
||||
associated materials may be copied, reproduced, modified,
|
||||
distributed, published, sublicensed, sold, or used to create
|
||||
derivative works without prior written permission from the
|
||||
copyright holder.
|
||||
|
||||
FUTURE LICENSE ACCEPTANCE:
|
||||
It is the copyright holder's intention to release this software in the future
|
||||
under a license yet to be defined, which will, among other things,
|
||||
allow private, non-commercial use. This statement does not constitute
|
||||
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.
|
||||
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
|
||||
OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
|
||||
EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
|
||||
NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
|
||||
LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
|
||||
|
|
|
|||
7
TODO
Normal file
7
TODO
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
- Add a bunch of tests
|
||||
- Add packages/hotkeys/
|
||||
- Full accessability
|
||||
- Add settings saving & update onboarding to use it
|
||||
- Add onboarding page to load one profile during onboarding
|
||||
|
||||
-> Most folders in packages/ have specific todos
|
||||
|
|
@ -95,7 +95,11 @@
|
|||
"target": [
|
||||
"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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -30,12 +30,22 @@ const verbose = process.argv.includes("--verbose");
|
|||
let mainWindow: BrowserWindow | null = null;
|
||||
let selectedScreenShareSourceId: string | null = null;
|
||||
|
||||
app.setName("tensamin");
|
||||
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
|
||||
|
||||
if (verbose) {
|
||||
app.commandLine.appendSwitch("enable-logging", "stderr");
|
||||
app.commandLine.appendSwitch("v", "1");
|
||||
app.commandLine.appendSwitch("log-level", "0");
|
||||
}
|
||||
|
||||
if (
|
||||
process.platform === "linux" &&
|
||||
!app.commandLine.hasSwitch("password-store")
|
||||
) {
|
||||
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
|
||||
}
|
||||
|
||||
if (
|
||||
process.platform === "linux" &&
|
||||
process.env.XDG_SESSION_TYPE === "wayland" &&
|
||||
|
|
@ -170,6 +180,29 @@ function registerDisplayMediaHandler() {
|
|||
);
|
||||
}
|
||||
|
||||
function registerMediaPermissionHandler() {
|
||||
const isTrustedRenderer = (url: string) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.protocol === "file:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
session.defaultSession.setPermissionCheckHandler(
|
||||
(_webContents, permission, requestingOrigin) =>
|
||||
permission === "media" && isTrustedRenderer(requestingOrigin),
|
||||
);
|
||||
session.defaultSession.setPermissionRequestHandler(
|
||||
(_webContents, permission, callback, details) => {
|
||||
callback(
|
||||
permission === "media" && isTrustedRenderer(details.requestingUrl),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
function registerIpc() {
|
||||
verboseLog("registering ipc handlers");
|
||||
|
||||
|
|
@ -350,6 +383,7 @@ async function start() {
|
|||
verboseLog("app ready");
|
||||
registerIpc();
|
||||
registerDisplayMediaHandler();
|
||||
registerMediaPermissionHandler();
|
||||
initTray(() => mainWindow);
|
||||
await createWindow();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,10 +34,6 @@ function validateValue(value: unknown): asserts value is string {
|
|||
}
|
||||
|
||||
export function getSecureStorageStatus(): DesktopSecureStorageStatus {
|
||||
if (!safeStorage.isEncryptionAvailable()) {
|
||||
return { available: false, backend: null };
|
||||
}
|
||||
|
||||
const backend =
|
||||
process.platform === "linux"
|
||||
? safeStorage.getSelectedStorageBackend()
|
||||
|
|
@ -48,7 +44,9 @@ export function getSecureStorageStatus(): DesktopSecureStorageStatus {
|
|||
: null;
|
||||
|
||||
return {
|
||||
available: process.platform !== "linux" || backend !== "basic_text",
|
||||
available:
|
||||
safeStorage.isEncryptionAvailable() &&
|
||||
(process.platform !== "linux" || backend !== "basic_text"),
|
||||
backend,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,14 +8,12 @@ const __filename = fileURLToPath(import.meta.url);
|
|||
const __dirname = path.dirname(__filename);
|
||||
|
||||
function getTrayIconPath(filename: string) {
|
||||
if (app.isPackaged) return path.join(process.resourcesPath, "icons", filename);
|
||||
if (app.isPackaged)
|
||||
return path.join(process.resourcesPath, "icons", filename);
|
||||
return path.resolve(__dirname, "../../build/icons", filename);
|
||||
}
|
||||
|
||||
export function setTrayCallStatus(
|
||||
inCall: boolean,
|
||||
iconDataUrl?: string,
|
||||
) {
|
||||
export function setTrayCallStatus(inCall: boolean, iconDataUrl?: string) {
|
||||
if (!tray) return;
|
||||
|
||||
if (inCall && iconDataUrl) {
|
||||
|
|
|
|||
3
apps/tauri/.gitignore
vendored
3
apps/tauri/.gitignore
vendored
|
|
@ -24,6 +24,3 @@ dist-ssr
|
|||
*.sw?
|
||||
|
||||
.android
|
||||
|
||||
/src-tauri/gen/android/keystore.properties
|
||||
/src-tauri/gen/android/keystore.jks
|
||||
|
|
|
|||
|
|
@ -4,10 +4,6 @@
|
|||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"exports": {
|
||||
"./controls": {
|
||||
"types": "./src/windowControls.tsx",
|
||||
"default": "./src/windowControls.tsx"
|
||||
},
|
||||
"./deeplinkHandler": {
|
||||
"types": "./src/deeplinkHandler.tsx",
|
||||
"default": "./src/deeplinkHandler.tsx"
|
||||
|
|
@ -35,7 +31,7 @@
|
|||
"@tauri-apps/plugin-log": "~2",
|
||||
"@tauri-apps/plugin-notification": "~2",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@methanium/ui": "*",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<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 -->
|
||||
<uses-feature android:name="android.software.leanback" android:required="false" />
|
||||
|
|
@ -39,6 +45,11 @@
|
|||
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
|
||||
</activity>
|
||||
|
||||
<service
|
||||
android:name=".MediaProjectionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaProjection" />
|
||||
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.fileprovider"
|
||||
|
|
|
|||
|
|
@ -1,20 +1,66 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.Manifest
|
||||
import android.app.Activity
|
||||
import android.content.pm.PackageManager
|
||||
import android.graphics.Rect
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.os.Bundle
|
||||
import android.view.ViewGroup
|
||||
import android.view.ViewTreeObserver
|
||||
import android.view.WindowManager
|
||||
import android.webkit.JavascriptInterface
|
||||
import android.webkit.WebView
|
||||
import android.widget.FrameLayout
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.core.content.ContextCompat
|
||||
import androidx.core.view.ViewCompat
|
||||
import androidx.core.view.WindowCompat
|
||||
import androidx.core.view.WindowInsetsCompat
|
||||
import org.json.JSONObject
|
||||
|
||||
class MainActivity : TauriActivity() {
|
||||
private var contentRoot: FrameLayout? = null
|
||||
private var contentChild: android.view.View? = null
|
||||
private var previousUsableHeight = 0
|
||||
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?) {
|
||||
WindowCompat.setDecorFitsSystemWindows(window, true)
|
||||
|
|
@ -30,9 +76,84 @@ class MainActivity : TauriActivity() {
|
|||
attachLayoutListener = null
|
||||
contentRoot = null
|
||||
contentChild = null
|
||||
mediaWebView?.removeJavascriptInterface("tensaminMobileMedia")
|
||||
mediaWebView = null
|
||||
MobileMediaEvents.detach()
|
||||
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() {
|
||||
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
|
||||
contentRoot = content
|
||||
|
|
|
|||
|
|
@ -0,0 +1,348 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.Manifest
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.graphics.Bitmap
|
||||
import android.graphics.PixelFormat
|
||||
import android.hardware.display.DisplayManager
|
||||
import android.hardware.display.VirtualDisplay
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioPlaybackCaptureConfiguration
|
||||
import android.media.AudioRecord
|
||||
import android.media.projection.MediaProjection
|
||||
import android.media.projection.MediaProjectionManager
|
||||
import android.media.ImageReader
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.HandlerThread
|
||||
import android.os.IBinder
|
||||
import android.util.Base64
|
||||
import android.util.DisplayMetrics
|
||||
import androidx.core.content.ContextCompat
|
||||
import java.io.ByteArrayOutputStream
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
import org.json.JSONObject
|
||||
|
||||
class MediaProjectionService : Service() {
|
||||
private var projection: MediaProjection? = null
|
||||
private var virtualDisplay: VirtualDisplay? = null
|
||||
private var imageReader: ImageReader? = null
|
||||
private var captureThread: HandlerThread? = null
|
||||
private var audioRecord: AudioRecord? = null
|
||||
private var audioThread: Thread? = null
|
||||
private val captureActive = AtomicBoolean(false)
|
||||
private var lastFrameAt = 0L
|
||||
|
||||
private val projectionCallback = object : MediaProjection.Callback() {
|
||||
override fun onStop() {
|
||||
stopCapture(stopProjection = false, emitStopped = true)
|
||||
stopSelf()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
if (intent?.action == ACTION_STOP) {
|
||||
stopCapture(stopProjection = true, emitStopped = true)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val permissionData = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
intent?.getParcelableExtra(EXTRA_PERMISSION_DATA, Intent::class.java)
|
||||
} else {
|
||||
@Suppress("DEPRECATION")
|
||||
intent?.getParcelableExtra(EXTRA_PERMISSION_DATA)
|
||||
}
|
||||
val resultCode = intent?.getIntExtra(EXTRA_RESULT_CODE, Int.MIN_VALUE) ?: Int.MIN_VALUE
|
||||
if (permissionData == null || resultCode == Int.MIN_VALUE) {
|
||||
MobileMediaEvents.emitError("Screen capture permission data is missing")
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
val includeAudio = intent?.getBooleanExtra(EXTRA_INCLUDE_AUDIO, false) ?: false
|
||||
try {
|
||||
startForegroundNotification()
|
||||
startCapture(resultCode, permissionData, includeAudio)
|
||||
} catch (error: Throwable) {
|
||||
stopCapture(stopProjection = true, emitStopped = false)
|
||||
stopForeground(STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
MobileMediaEvents.emitError(error.message ?: "Unable to start screen capture")
|
||||
}
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
stopCapture(stopProjection = true, emitStopped = true)
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
private fun startForegroundNotification() {
|
||||
val notificationManager = getSystemService(NotificationManager::class.java)
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
notificationManager.createNotificationChannel(
|
||||
NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
"Screen sharing",
|
||||
NotificationManager.IMPORTANCE_LOW,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
val stopIntent = Intent(this, MediaProjectionService::class.java).setAction(ACTION_STOP)
|
||||
val stopPendingIntent = PendingIntent.getService(
|
||||
this,
|
||||
0,
|
||||
stopIntent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
val notification = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
android.app.Notification.Builder(this, NOTIFICATION_CHANNEL_ID)
|
||||
} else {
|
||||
android.app.Notification.Builder(this)
|
||||
}
|
||||
.setSmallIcon(android.R.drawable.ic_menu_share)
|
||||
.setContentTitle("Tensamin is sharing your screen")
|
||||
.setContentText("Tap Stop to end screen sharing")
|
||||
.setOngoing(true)
|
||||
.setCategory(android.app.Notification.CATEGORY_SERVICE)
|
||||
.addAction(android.R.drawable.ic_media_pause, "Stop", stopPendingIntent)
|
||||
.build()
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
|
||||
startForeground(
|
||||
NOTIFICATION_ID,
|
||||
notification,
|
||||
ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PROJECTION,
|
||||
)
|
||||
} else {
|
||||
startForeground(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
private fun startCapture(resultCode: Int, permissionData: Intent, includeAudio: Boolean) {
|
||||
stopCapture(stopProjection = true, emitStopped = false)
|
||||
|
||||
val manager = getSystemService(MediaProjectionManager::class.java)
|
||||
val newProjection = manager.getMediaProjection(resultCode, permissionData)
|
||||
?: error("Android did not provide a screen capture session")
|
||||
projection = newProjection
|
||||
|
||||
val thread = HandlerThread("tensamin-screen-capture").also { it.start() }
|
||||
captureThread = thread
|
||||
val handler = Handler(thread.looper)
|
||||
newProjection.registerCallback(projectionCallback, handler)
|
||||
|
||||
val metrics = DisplayMetrics()
|
||||
getSystemService(android.view.WindowManager::class.java).defaultDisplay.getRealMetrics(metrics)
|
||||
val displayWidth = metrics.widthPixels
|
||||
val displayHeight = metrics.heightPixels
|
||||
val width = minOf(displayWidth, MAX_FRAME_WIDTH)
|
||||
val height = (displayHeight.toLong() * width / displayWidth).toInt()
|
||||
val density = metrics.densityDpi
|
||||
val reader = ImageReader.newInstance(width, height, PixelFormat.RGBA_8888, 2)
|
||||
imageReader = reader
|
||||
reader.setOnImageAvailableListener({ source -> captureFrame(source, width, height) }, handler)
|
||||
|
||||
virtualDisplay = newProjection.createVirtualDisplay(
|
||||
"Tensamin screen sharing",
|
||||
width,
|
||||
height,
|
||||
density,
|
||||
DisplayManager.VIRTUAL_DISPLAY_FLAG_AUTO_MIRROR,
|
||||
reader.surface,
|
||||
null,
|
||||
handler,
|
||||
)
|
||||
captureActive.set(true)
|
||||
|
||||
val audioStarted = includeAudio && startAudioCapture(newProjection)
|
||||
MobileMediaEvents.emit(
|
||||
"tensamin-mobile-screen-started",
|
||||
JSONObject().put("includeAudio", audioStarted),
|
||||
)
|
||||
}
|
||||
|
||||
private fun captureFrame(source: ImageReader, width: Int, height: Int) {
|
||||
val image = source.acquireLatestImage() ?: return
|
||||
try {
|
||||
val now = System.currentTimeMillis()
|
||||
if (!captureActive.get() || now - lastFrameAt < FRAME_INTERVAL_MS) return
|
||||
lastFrameAt = now
|
||||
|
||||
val plane = image.planes[0]
|
||||
val paddedWidth = plane.rowStride / plane.pixelStride
|
||||
val paddedBitmap = Bitmap.createBitmap(paddedWidth, height, Bitmap.Config.ARGB_8888)
|
||||
paddedBitmap.copyPixelsFromBuffer(plane.buffer)
|
||||
val croppedBitmap = if (paddedWidth == width) {
|
||||
paddedBitmap
|
||||
} else {
|
||||
Bitmap.createBitmap(paddedBitmap, 0, 0, width, height).also { paddedBitmap.recycle() }
|
||||
}
|
||||
val outputBitmap = croppedBitmap
|
||||
|
||||
val bytes = ByteArrayOutputStream()
|
||||
outputBitmap.compress(Bitmap.CompressFormat.JPEG, JPEG_QUALITY, bytes)
|
||||
val outputWidth = outputBitmap.width
|
||||
val outputHeight = outputBitmap.height
|
||||
outputBitmap.recycle()
|
||||
MobileMediaEvents.emit(
|
||||
"tensamin-mobile-screen-frame",
|
||||
JSONObject()
|
||||
.put("data", Base64.encodeToString(bytes.toByteArray(), Base64.NO_WRAP))
|
||||
.put("mimeType", "image/jpeg")
|
||||
.put("width", outputWidth)
|
||||
.put("height", outputHeight),
|
||||
)
|
||||
} catch (error: Throwable) {
|
||||
if (captureActive.get()) {
|
||||
MobileMediaEvents.emitError(error.message ?: "Unable to read a screen frame")
|
||||
}
|
||||
} finally {
|
||||
image.close()
|
||||
}
|
||||
}
|
||||
|
||||
private fun startAudioCapture(activeProjection: MediaProjection): Boolean {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
|
||||
MobileMediaEvents.emitError("System audio capture requires Android 10 or newer")
|
||||
return false
|
||||
}
|
||||
if (ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
|
||||
PackageManager.PERMISSION_GRANTED
|
||||
) {
|
||||
MobileMediaEvents.emitError("Microphone permission is required for system audio capture")
|
||||
return false
|
||||
}
|
||||
|
||||
return try {
|
||||
val format = AudioFormat.Builder()
|
||||
.setEncoding(AudioFormat.ENCODING_PCM_16BIT)
|
||||
.setSampleRate(AUDIO_SAMPLE_RATE)
|
||||
.setChannelMask(AudioFormat.CHANNEL_IN_MONO)
|
||||
.build()
|
||||
val configuration = AudioPlaybackCaptureConfiguration.Builder(activeProjection)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.addMatchingUsage(AudioAttributes.USAGE_GAME)
|
||||
.build()
|
||||
val minimumBuffer = AudioRecord.getMinBufferSize(
|
||||
AUDIO_SAMPLE_RATE,
|
||||
AudioFormat.CHANNEL_IN_MONO,
|
||||
AudioFormat.ENCODING_PCM_16BIT,
|
||||
)
|
||||
check(minimumBuffer > 0) { "Android could not allocate a system audio buffer" }
|
||||
val bufferSize = maxOf(minimumBuffer * 2, AUDIO_BATCH_BYTES)
|
||||
val record = AudioRecord.Builder()
|
||||
.setAudioFormat(format)
|
||||
.setAudioPlaybackCaptureConfig(configuration)
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
.build()
|
||||
check(record.state == AudioRecord.STATE_INITIALIZED) {
|
||||
"Android could not initialize system audio capture"
|
||||
}
|
||||
audioRecord = record
|
||||
record.startRecording()
|
||||
audioThread = Thread({ readAudio(record) }, "tensamin-audio-capture").also { it.start() }
|
||||
true
|
||||
} catch (error: Throwable) {
|
||||
audioRecord?.release()
|
||||
audioRecord = null
|
||||
MobileMediaEvents.emitError(error.message ?: "Unable to capture system audio")
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun readAudio(record: AudioRecord) {
|
||||
val buffer = ByteArray(AUDIO_BATCH_BYTES)
|
||||
while (captureActive.get() && !Thread.currentThread().isInterrupted) {
|
||||
val read = record.read(buffer, 0, buffer.size, AudioRecord.READ_BLOCKING)
|
||||
if (read > 0 && captureActive.get()) {
|
||||
MobileMediaEvents.emit(
|
||||
"tensamin-mobile-screen-audio",
|
||||
JSONObject()
|
||||
.put("data", Base64.encodeToString(buffer, 0, read, Base64.NO_WRAP))
|
||||
.put("sampleRate", AUDIO_SAMPLE_RATE)
|
||||
.put("channelCount", 1)
|
||||
.put("encoding", "pcm16le"),
|
||||
)
|
||||
} else if (read < 0 && captureActive.get()) {
|
||||
MobileMediaEvents.emitError("System audio capture stopped with code $read")
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
private fun stopCapture(stopProjection: Boolean, emitStopped: Boolean) {
|
||||
val wasActive = captureActive.getAndSet(false)
|
||||
|
||||
val record = audioRecord
|
||||
audioRecord = null
|
||||
try {
|
||||
record?.stop()
|
||||
} catch (_: IllegalStateException) {
|
||||
}
|
||||
record?.release()
|
||||
audioThread?.interrupt()
|
||||
audioThread = null
|
||||
|
||||
imageReader?.setOnImageAvailableListener(null, null)
|
||||
virtualDisplay?.release()
|
||||
virtualDisplay = null
|
||||
imageReader?.close()
|
||||
imageReader = null
|
||||
|
||||
val oldProjection = projection
|
||||
projection = null
|
||||
oldProjection?.unregisterCallback(projectionCallback)
|
||||
if (stopProjection) oldProjection?.stop()
|
||||
|
||||
captureThread?.quitSafely()
|
||||
captureThread = null
|
||||
lastFrameAt = 0L
|
||||
|
||||
if (wasActive && emitStopped) {
|
||||
MobileMediaEvents.emit("tensamin-mobile-screen-stopped")
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val ACTION_STOP = "net.tensamin.client.STOP_SCREEN_SHARE"
|
||||
private const val EXTRA_RESULT_CODE = "resultCode"
|
||||
private const val EXTRA_PERMISSION_DATA = "permissionData"
|
||||
private const val EXTRA_INCLUDE_AUDIO = "includeAudio"
|
||||
private const val NOTIFICATION_CHANNEL_ID = "screen-sharing"
|
||||
private const val NOTIFICATION_ID = 7314
|
||||
private const val FRAME_INTERVAL_MS = 75L
|
||||
private const val MAX_FRAME_WIDTH = 1280
|
||||
private const val JPEG_QUALITY = 72
|
||||
private const val AUDIO_SAMPLE_RATE = 48_000
|
||||
private const val AUDIO_BATCH_BYTES = 9_600
|
||||
|
||||
fun start(context: Context, resultCode: Int, data: Intent, includeAudio: Boolean) {
|
||||
val intent = Intent(context, MediaProjectionService::class.java)
|
||||
.putExtra(EXTRA_RESULT_CODE, resultCode)
|
||||
.putExtra(EXTRA_PERMISSION_DATA, data)
|
||||
.putExtra(EXTRA_INCLUDE_AUDIO, includeAudio)
|
||||
ContextCompat.startForegroundService(context, intent)
|
||||
}
|
||||
|
||||
fun stop(context: Context) {
|
||||
context.startService(
|
||||
Intent(context, MediaProjectionService::class.java).setAction(ACTION_STOP),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
package net.tensamin.client
|
||||
|
||||
import android.webkit.WebView
|
||||
import java.lang.ref.WeakReference
|
||||
import org.json.JSONObject
|
||||
|
||||
object MobileMediaEvents {
|
||||
private var webView = WeakReference<WebView>(null)
|
||||
|
||||
fun attach(value: WebView) {
|
||||
webView = WeakReference(value)
|
||||
}
|
||||
|
||||
fun detach() {
|
||||
webView.clear()
|
||||
}
|
||||
|
||||
fun emitError(message: String) {
|
||||
emit("tensamin-mobile-screen-error", JSONObject().put("message", message))
|
||||
}
|
||||
|
||||
fun emit(name: String, detail: JSONObject = JSONObject()) {
|
||||
val view = webView.get() ?: return
|
||||
val script = "window.dispatchEvent(new CustomEvent(" +
|
||||
JSONObject.quote(name) +
|
||||
", { detail: " + detail.toString() + " }));"
|
||||
view.post { view.evaluateJavascript(script, null) }
|
||||
}
|
||||
}
|
||||
|
|
@ -7,7 +7,7 @@ import {
|
|||
} from "react";
|
||||
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { useIsMobile } from "@tensamin/ui";
|
||||
import { useIsMobile } from "@methanium/ui";
|
||||
|
||||
type DeeplinkContextValue = {
|
||||
deeplinks: readonly string[];
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import {
|
|||
Format,
|
||||
requestPermissions,
|
||||
} from "@tauri-apps/plugin-barcode-scanner";
|
||||
import { Button } from "@tensamin/ui";
|
||||
import { Button } from "@methanium/ui";
|
||||
|
||||
import { toast } from "@tensamin/shared/log";
|
||||
|
||||
|
|
|
|||
|
|
@ -60,9 +60,10 @@
|
|||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/tauth": "workspace:*",
|
||||
"@tensamin/markdown": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@methanium/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"@tensamin/notifications": "workspace:*",
|
||||
"@tensamin/onboarding": "workspace:*",
|
||||
"aria-hidden": "^1.2.4",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
|
|
|
|||
BIN
apps/web/public/sounds/call_jingle_1.wav
Normal file
BIN
apps/web/public/sounds/call_jingle_1.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/call_jingle_2.wav
Normal file
BIN
apps/web/public/sounds/call_jingle_2.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/call_join.wav
Normal file
BIN
apps/web/public/sounds/call_join.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/call_leave.wav
Normal file
BIN
apps/web/public/sounds/call_leave.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/message.wav
Normal file
BIN
apps/web/public/sounds/message.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_end_other.wav
Normal file
BIN
apps/web/public/sounds/stream_end_other.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_end_self.wav
Normal file
BIN
apps/web/public/sounds/stream_end_self.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_start_other.wav
Normal file
BIN
apps/web/public/sounds/stream_start_other.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_start_self.wav
Normal file
BIN
apps/web/public/sounds/stream_start_self.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_watch_end.wav
Normal file
BIN
apps/web/public/sounds/stream_watch_end.wav
Normal file
Binary file not shown.
BIN
apps/web/public/sounds/stream_watch_start.wav
Normal file
BIN
apps/web/public/sounds/stream_watch_start.wav
Normal file
Binary file not shown.
|
|
@ -6,9 +6,9 @@ import {
|
|||
Tooltip,
|
||||
TooltipTrigger,
|
||||
TooltipContent,
|
||||
} from "@tensamin/ui";
|
||||
import { Card, CardHeader } from "@tensamin/ui";
|
||||
import { Skeleton } from "@tensamin/ui";
|
||||
Button,
|
||||
} from "@methanium/ui";
|
||||
import { Skeleton } from "@methanium/ui";
|
||||
import { getStatusColor } from "@tensamin/shared/data";
|
||||
|
||||
export function Basic({
|
||||
|
|
@ -18,42 +18,50 @@ export function Basic({
|
|||
user: User;
|
||||
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 (
|
||||
<Card className="animate-in fade-in duration-300 rounded-xl py-0 h-12.5!">
|
||||
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2">
|
||||
<div className="relative shrink-0 overflow-visible">
|
||||
<Avatar>
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback>
|
||||
{user.Display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<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">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: getStatusColor(user.OnlineStatus),
|
||||
}}
|
||||
className="h-2.25 w-2.25 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{user.OnlineStatus.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]">
|
||||
<p>{user.Display}</p>
|
||||
</div>
|
||||
<div className="pr-1">{extra}</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<Button
|
||||
render={<div />}
|
||||
nativeButton={false}
|
||||
variant="outline"
|
||||
className="outline-none! animate-in fade-in duration-300 h-auto w-full justify-start gap-2.5 rounded-xl min-h-12.5!"
|
||||
>
|
||||
<div className="relative shrink-0 overflow-visible">
|
||||
<Avatar>
|
||||
<AvatarImage src={avatar} />
|
||||
<AvatarFallback>{display.slice(0, 2).toUpperCase()}</AvatarFallback>
|
||||
</Avatar>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<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">
|
||||
<div
|
||||
style={{
|
||||
backgroundColor: getStatusColor(onlineStatus),
|
||||
}}
|
||||
className="h-2.25 w-2.25 rounded-full"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<TooltipContent>
|
||||
{onlineStatus
|
||||
.split("_")
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" ")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="flex w-full flex-col items-start justify-center gap-1 text-[15px]">
|
||||
<p>{display}</p>
|
||||
</div>
|
||||
<div className="pr-1">{extra}</div>
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import type { User } from "@tensamin/user/context";
|
||||
import { Avatar, AvatarFallback, AvatarImage, Button } from "@tensamin/ui";
|
||||
import { Avatar, AvatarFallback, AvatarImage, Button } from "@methanium/ui";
|
||||
import Text from "@tensamin/markdown/text";
|
||||
import { ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { useState } from "react";
|
||||
|
|
@ -24,7 +24,7 @@ export default function Profile({ user }: { user: User }) {
|
|||
<Text value={user.About || ""} />
|
||||
<Button
|
||||
className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline"
|
||||
variant="link"
|
||||
variant="ghost"
|
||||
aria-expanded={showAdvancedInformation}
|
||||
onClick={() => setShowAdvancedInformation((show) => !show)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -4,17 +4,29 @@ import {
|
|||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
useIsMobile,
|
||||
} from "@tensamin/ui";
|
||||
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react";
|
||||
} from "@methanium/ui";
|
||||
import {
|
||||
ArrowLeft,
|
||||
EllipsisVertical,
|
||||
House,
|
||||
Phone,
|
||||
Settings,
|
||||
User,
|
||||
} from "lucide-react";
|
||||
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
|
||||
import { joinCall, useCall } from "@tensamin/call/store";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
import { Skeleton } from "@tensamin/ui";
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger } from "@tensamin/ui";
|
||||
import { Skeleton } from "@methanium/ui";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@methanium/ui";
|
||||
import { displayCallId } from "@tensamin/call/utils";
|
||||
import { useState } from "react";
|
||||
import { SidebarTrigger, useSidebar } from "@tensamin/ui";
|
||||
import { WindowControls as Controls } from "@tensamin/ui";
|
||||
import { SidebarTrigger, useSidebar } from "@methanium/ui";
|
||||
import { WindowControls as Controls } from "@methanium/ui";
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
import Profile from "./modals/profile";
|
||||
|
||||
|
|
@ -35,37 +47,47 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
|
||||
const [userInfoOpen, setUserInfoOpen] = useState(false);
|
||||
|
||||
const { callId } = useCall();
|
||||
|
||||
return (
|
||||
<div
|
||||
data-tauri-drag-region
|
||||
className={`${forMobile && "border-b"} w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
|
||||
className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
{forMobile ? (
|
||||
<SidebarTrigger
|
||||
className="w-9 h-9 aspect-square rounded-lg ml-2"
|
||||
variant="outline"
|
||||
>
|
||||
<ArrowLeft className="size-4.5" />
|
||||
</SidebarTrigger>
|
||||
render={({ onClick }) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
className="w-9 h-9! ml-2"
|
||||
variant="ghost"
|
||||
>
|
||||
<ArrowLeft className="size-4.5" />
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
onClick={() => navigate({ to: "/" })}
|
||||
className="w-9 h-9 aspect-square rounded-lg"
|
||||
className="w-9 h-9! aspect-square rounded-lg"
|
||||
variant="outline"
|
||||
>
|
||||
<House className="size-4.5" />
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => navigate({ to: "/settings" })}
|
||||
className="w-9 h-9 aspect-square rounded-lg"
|
||||
className="w-9 h-9! aspect-square rounded-lg"
|
||||
variant="outline"
|
||||
>
|
||||
<Settings className="size-4.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{isMobile && pathname === "/call" && callId && (
|
||||
<p className="font-medium text-[1.07rem]">{displayCallId(callId)}</p>
|
||||
)}
|
||||
{pathname === "/chat" && id && (
|
||||
<Wrapper
|
||||
userId={id}
|
||||
|
|
@ -75,10 +97,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
) : (
|
||||
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
render={({ onClick }) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
variant="link"
|
||||
className="px-0! text-foreground"
|
||||
className="text-foreground px-0! border-0! bg-none! bg-transparent!"
|
||||
style={{
|
||||
textDecorationLine: "none",
|
||||
}}
|
||||
|
|
@ -87,7 +110,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
{user?.Display}
|
||||
</p>
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<PopoverContent side="bottom">
|
||||
<Profile user={user} />
|
||||
|
|
@ -109,7 +132,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
onClick={() => {
|
||||
void joinCall(id);
|
||||
}}
|
||||
className="w-9 h-9 aspect-square rounded-lg"
|
||||
className="w-9 h-9! aspect-square rounded-lg"
|
||||
variant="outline"
|
||||
>
|
||||
<Phone />
|
||||
|
|
@ -124,7 +147,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
currentCalls[0].CallId,
|
||||
);
|
||||
}}
|
||||
className="w-9 h-9 aspect-square rounded-lg"
|
||||
className="w-9 h-9! aspect-square rounded-lg"
|
||||
>
|
||||
<Phone />
|
||||
</Button>
|
||||
|
|
@ -161,6 +184,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
|
|||
)}
|
||||
</>
|
||||
)}
|
||||
{isMobile && pathname === "/call" && callId && (
|
||||
<Button variant="ghost">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
)}
|
||||
<Controls className="mr-2" />
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -175,7 +203,7 @@ export function MobileNavbar() {
|
|||
<div className="z-51 w-full shrink-0 pb-[env(safe-area-inset-bottom)] bg-card border-t">
|
||||
<div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5">
|
||||
<SidebarTrigger
|
||||
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1"
|
||||
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1 border-0!"
|
||||
variant="link"
|
||||
>
|
||||
<User className="size-4.5" />
|
||||
|
|
@ -186,7 +214,7 @@ export function MobileNavbar() {
|
|||
navigate({ to: "/" });
|
||||
setOpenMobile(false);
|
||||
}}
|
||||
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
|
||||
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!"
|
||||
variant="link"
|
||||
>
|
||||
<House className="size-4.5" />
|
||||
|
|
@ -197,7 +225,7 @@ export function MobileNavbar() {
|
|||
navigate({ to: "/settings" });
|
||||
setOpenMobile(false);
|
||||
}}
|
||||
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1"
|
||||
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!"
|
||||
variant="link"
|
||||
>
|
||||
<Settings className="size-4.5" />
|
||||
|
|
|
|||
|
|
@ -1,13 +1,21 @@
|
|||
import { Button, cn, useIsMobile } from "@tensamin/ui";
|
||||
import { Input } from "@tensamin/ui";
|
||||
import { Label } from "@tensamin/ui";
|
||||
import { Button, cn, useIsMobile } from "@methanium/ui";
|
||||
import { Input } from "@methanium/ui";
|
||||
import { Label } from "@methanium/ui";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { File } from "lucide-react";
|
||||
import * as React from "react";
|
||||
import {
|
||||
type ChangeEvent,
|
||||
type FormEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { z } from "zod";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
|
||||
const fetchedUser = z.object({
|
||||
id: z.uuidv4(),
|
||||
|
|
@ -77,12 +85,13 @@ function parseTuFileContent(rawFileContent: string): {
|
|||
|
||||
export default function Form() {
|
||||
const isMobile = useIsMobile();
|
||||
const uploadRef = React.useRef<HTMLInputElement | null>(null);
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
const uploadRef = useRef<HTMLInputElement | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const { save } = useStorage();
|
||||
const loginPendingRef = React.useRef(false);
|
||||
const navigate = useNavigate();
|
||||
const loginPendingRef = useRef(false);
|
||||
|
||||
const persistLogin = React.useCallback(
|
||||
const persistLogin = useCallback(
|
||||
async (userId: number, privateKey: string, domain?: string | null) => {
|
||||
if (loginPendingRef.current) return false;
|
||||
loginPendingRef.current = true;
|
||||
|
|
@ -91,22 +100,17 @@ export default function Form() {
|
|||
await save("mtp_keyring", privateKey, { secure: true });
|
||||
await save("session_id", Date.now());
|
||||
await save("user_id", userId);
|
||||
window.history.replaceState(
|
||||
null,
|
||||
"",
|
||||
window.location.protocol === "file:" ? "#/" : "/",
|
||||
);
|
||||
window.location.reload();
|
||||
await navigate({ to: "/", replace: true });
|
||||
return true;
|
||||
} finally {
|
||||
loginPendingRef.current = false;
|
||||
}
|
||||
},
|
||||
[save],
|
||||
[navigate, save],
|
||||
);
|
||||
|
||||
// Process dropped files
|
||||
const processDroppedFile = React.useCallback(
|
||||
const processDroppedFile = useCallback(
|
||||
async (file: globalThis.File): Promise<void> => {
|
||||
try {
|
||||
if (!file.name.endsWith(".tu")) {
|
||||
|
|
@ -127,8 +131,8 @@ export default function Form() {
|
|||
);
|
||||
|
||||
// Handle .tu files
|
||||
const handleFileInputChange = React.useCallback(
|
||||
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const handleFileInputChange = useCallback(
|
||||
async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
|
||||
const file = event.currentTarget.files?.[0];
|
||||
|
||||
if (!file) {
|
||||
|
|
@ -142,7 +146,7 @@ export default function Form() {
|
|||
);
|
||||
|
||||
// Drag and drop listener
|
||||
React.useEffect(() => {
|
||||
useEffect(() => {
|
||||
let dragCounter = 0;
|
||||
|
||||
const handleDragEnter = (event: DragEvent) => {
|
||||
|
|
@ -212,8 +216,8 @@ export default function Form() {
|
|||
* @param event Form submit event.
|
||||
* @returns Promise that resolves after login processing.
|
||||
*/
|
||||
const handleCredentialsSubmit = React.useCallback(
|
||||
async (event: React.FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
const handleCredentialsSubmit = useCallback(
|
||||
async (event: FormEvent<HTMLFormElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
|
||||
const formData = new FormData(event.currentTarget);
|
||||
|
|
|
|||
|
|
@ -26,9 +26,9 @@ import {
|
|||
SelectValue,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { useIsMobile } from "@tensamin/ui";
|
||||
import { useIsMobile } from "@methanium/ui";
|
||||
import { MobileNavbar } from "./navbar";
|
||||
|
||||
import SidebarBox from "@tensamin/call/sidebarBox";
|
||||
|
|
@ -298,7 +298,7 @@ export default function Sidebar() {
|
|||
data-sidebar="sidebar"
|
||||
data-slot="sidebar"
|
||||
data-mobile="true"
|
||||
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground transition-[transform,opacity] duration-150 ease-linear"
|
||||
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground"
|
||||
style={{
|
||||
transform: openMobile ? "translateX(0)" : "translateX(-100%)",
|
||||
opacity: openMobile ? 1 : 0,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import * as React from "react";
|
||||
import { useRef, useState } from "react";
|
||||
import { useVirtualizer } from "@tanstack/react-virtual";
|
||||
|
||||
import Switch from "./switch";
|
||||
|
|
@ -8,14 +8,14 @@ import { Loader2 } from "lucide-react";
|
|||
import { useSession } from "@tensamin/storage/session";
|
||||
|
||||
export default function List() {
|
||||
const [category, setCategory] = React.useState<
|
||||
"conversations" | "communities"
|
||||
>("conversations");
|
||||
const [category, setCategory] = useState<"conversations" | "communities">(
|
||||
"conversations",
|
||||
);
|
||||
|
||||
const { contacts, communities } = useSession();
|
||||
const items = category === "conversations" ? contacts : communities;
|
||||
|
||||
const scrollRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const scrollRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
// TanStack Virtual is intentionally used here; React Compiler memoization is skipped.
|
||||
// eslint-disable-next-line react-hooks/incompatible-library
|
||||
|
|
@ -31,7 +31,7 @@ export default function List() {
|
|||
<div
|
||||
ref={scrollRef}
|
||||
id="conversation-list"
|
||||
className="overflow-y-auto flex-1 h-full"
|
||||
className="overflow-y-auto flex-1 h-full p-px"
|
||||
>
|
||||
{items === null ? (
|
||||
<div className="flex items-center justify-center pt-5">
|
||||
|
|
|
|||
|
|
@ -6,9 +6,9 @@ import {
|
|||
ContextMenuGroup,
|
||||
ContextMenuItem,
|
||||
ContextMenuTrigger,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import { useNavigate } from "@tanstack/react-router";
|
||||
import { useSidebar } from "@tensamin/ui";
|
||||
import { useSidebar } from "@methanium/ui";
|
||||
|
||||
export default function ConversationModal({ userId }: { userId: number }) {
|
||||
const navigate = useNavigate();
|
||||
|
|
|
|||
|
|
@ -1,247 +0,0 @@
|
|||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { Button } from "@tensamin/ui";
|
||||
import { Checkbox } from "@tensamin/ui";
|
||||
import { z } from "zod";
|
||||
|
||||
import { ErrorScreen } from "@tensamin/ui";
|
||||
|
||||
import { legalDocsSchema } from "@tensamin/shared/features/legal/schema";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
import { Link } from "@tensamin/ui";
|
||||
import { Label } from "@tensamin/ui";
|
||||
import { CreateScreen } from "@tensamin/ui";
|
||||
|
||||
// Prevents the user from using Tensamin without accepting the privacy policy and terms of service.
|
||||
export default function Screen(props: { children: React.ReactNode }) {
|
||||
const { load, save } = useStorage();
|
||||
|
||||
const [error, setError] = useState("");
|
||||
const [errorDescription, setErrorDescription] = useState("");
|
||||
|
||||
const [remoteDocs, setRemoteDocs] = useState<
|
||||
z.infer<typeof legalDocsSchema> | undefined
|
||||
>(undefined);
|
||||
const [localDocs, setLocalDocs] = useState<
|
||||
z.infer<typeof legalDocsSchema> | undefined
|
||||
>(undefined);
|
||||
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const [acceptedPP, acceptPP] = useState(false);
|
||||
const [acceptedTOS, acceptTOS] = useState(false);
|
||||
const [hasContinued, setHasContinued] = useState(false);
|
||||
|
||||
const [userId, setUserId] = useState<number | undefined>(undefined);
|
||||
|
||||
// Saves
|
||||
const handleContinueLegal = useCallback((): void => {
|
||||
const currentDocs = remoteDocs;
|
||||
if (!currentDocs) {
|
||||
return;
|
||||
}
|
||||
|
||||
save("accepted_privacy_policy", true);
|
||||
save("accepted_terms_of_service", true);
|
||||
save("legal_docs", currentDocs);
|
||||
setHasContinued(true);
|
||||
}, [remoteDocs, save]);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
void (async () => {
|
||||
try {
|
||||
const id = await load("user_id");
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setUserId(id);
|
||||
|
||||
if (id === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = await fetch("https://legal.tensamin.net/api/current")
|
||||
.then((res) => res.json())
|
||||
.catch((err) => {
|
||||
if (!active) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
setError("Failed to load legal documents");
|
||||
setErrorDescription(
|
||||
"An error occurred while fetching the legal documents from the server. Please try again later.",
|
||||
);
|
||||
log(0, "Legal", "red", "Failed to fetch legal documents", err);
|
||||
return undefined;
|
||||
});
|
||||
|
||||
if (!active || current === undefined) {
|
||||
return;
|
||||
}
|
||||
|
||||
const safeCurrent = legalDocsSchema.safeParse(current);
|
||||
if (!safeCurrent.success) {
|
||||
setError("Failed to load legal documents");
|
||||
setErrorDescription(
|
||||
"The legal documents data received from the server is invalid. Please try again later.",
|
||||
);
|
||||
log(
|
||||
0,
|
||||
"Legal",
|
||||
"red",
|
||||
"Invalid legal documents data",
|
||||
safeCurrent.error,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setRemoteDocs(safeCurrent.data);
|
||||
|
||||
const currentLocalDocs = await load("legal_docs");
|
||||
setLocalDocs(currentLocalDocs);
|
||||
|
||||
const [loadedAcceptedPP, loadedAcceptedTOS] = await Promise.all([
|
||||
load("accepted_privacy_policy"),
|
||||
load("accepted_terms_of_service"),
|
||||
]);
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
acceptPP(
|
||||
loadedAcceptedPP &&
|
||||
!!currentLocalDocs &&
|
||||
currentLocalDocs.pp.hash === safeCurrent.data.pp.hash,
|
||||
);
|
||||
acceptTOS(
|
||||
loadedAcceptedTOS &&
|
||||
!!currentLocalDocs &&
|
||||
currentLocalDocs.tos.hash === safeCurrent.data.tos.hash,
|
||||
);
|
||||
} finally {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [load]);
|
||||
|
||||
if (error !== "" && errorDescription !== "") {
|
||||
return <ErrorScreen error={error} description={errorDescription} />;
|
||||
}
|
||||
|
||||
if (loading || userId === undefined) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const docsMatch =
|
||||
localDocs !== undefined &&
|
||||
remoteDocs !== undefined &&
|
||||
localDocs.pp.hash === remoteDocs.pp.hash &&
|
||||
localDocs.tos.hash === remoteDocs.tos.hash;
|
||||
|
||||
if (
|
||||
(acceptedPP && acceptedTOS && (docsMatch || hasContinued)) ||
|
||||
userId === 0
|
||||
) {
|
||||
return <>{props.children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<CreateScreen>
|
||||
<div className="h-full flex flex-col gap-15 p-10 py-20 md:p-40 w-full lg:w-2/3">
|
||||
<h1 className="text-3xl md:text-4xl font-bold">
|
||||
Privacy Policy & ToS
|
||||
<p className="text-muted-foreground text-[20px] font-normal pt-3">
|
||||
{remoteDocs?.pp.version} / {remoteDocs?.tos.version}
|
||||
</p>
|
||||
</h1>
|
||||
<div className="w-full h-full flex flex-col items-center justify-center gap-5">
|
||||
<div className="justify-start items-start flex flex-col gap-2">
|
||||
<BigCheckbox
|
||||
id="acceptPP"
|
||||
checked={acceptedPP}
|
||||
onChange={acceptPP}
|
||||
label="I agree to the Privacy Policy"
|
||||
/>
|
||||
<BigCheckbox
|
||||
id="acceptTOS"
|
||||
checked={acceptedTOS}
|
||||
onChange={acceptTOS}
|
||||
label="I agree to the Terms of Service"
|
||||
/>
|
||||
<div className="w-full border-t-2" />
|
||||
<Link
|
||||
label="Privacy Policy"
|
||||
link={`https://legal.tensamin.net/pp/${remoteDocs?.pp.version}`}
|
||||
/>
|
||||
<Link
|
||||
label="Terms of Service"
|
||||
link={`https://legal.tensamin.net/tos/${remoteDocs?.tos.version}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<ContinueButton
|
||||
disabled={!acceptedPP || !acceptedTOS}
|
||||
onClick={handleContinueLegal}
|
||||
/>
|
||||
</div>
|
||||
</CreateScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// Components
|
||||
function ContinueButton({
|
||||
onClick,
|
||||
disabled,
|
||||
}: {
|
||||
onClick: () => void;
|
||||
disabled?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="w-full flex justify-end">
|
||||
<Button
|
||||
size="lg"
|
||||
className="text-md w-full md:w-auto"
|
||||
onClick={onClick}
|
||||
disabled={disabled}
|
||||
>
|
||||
Continue
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function BigCheckbox({
|
||||
id,
|
||||
label,
|
||||
checked,
|
||||
onChange,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
checked: boolean;
|
||||
onChange: (checked: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id={id}
|
||||
checked={checked}
|
||||
onCheckedChange={onChange}
|
||||
className="size-5.5 rounded-md flex items-center justify-center"
|
||||
/>
|
||||
<Label htmlFor={id} className="text-lg">
|
||||
{label}
|
||||
</Label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,19 +9,19 @@ import {
|
|||
} from "@tanstack/react-router";
|
||||
|
||||
import "./index.css";
|
||||
import "@tensamin/ui/index.css";
|
||||
import "@methanium/ui/index.css";
|
||||
|
||||
import NotFound from "@/routes/404";
|
||||
|
||||
import AppLayout from "@/routes/app/layout";
|
||||
import { createSettingsRoute } from "@tensamin/settings";
|
||||
import OnboardingGate from "@tensamin/onboarding";
|
||||
|
||||
import Home from "@/routes/app/home";
|
||||
import ChatScreen from "@tensamin/chat/screen";
|
||||
import CallScreen from "@tensamin/call/screen";
|
||||
import Login from "@/routes/screens/login";
|
||||
|
||||
import CallPopout from "@tensamin/call/popout";
|
||||
import ChatContext from "@tensamin/chat/context";
|
||||
import { useCall, useInitializeCall } from "@tensamin/call/store";
|
||||
import { useIsSpeaking } from "@tensamin/call/speakingState";
|
||||
|
|
@ -32,7 +32,7 @@ import NotificationsProvider from "@tensamin/notifications/context";
|
|||
|
||||
import TAuthWrapper from "@tensamin/tauth/context";
|
||||
|
||||
import { ErrorScreen, ThemeProvider, useTheme } from "@tensamin/ui";
|
||||
import { ErrorScreen, ThemeProvider, useTheme } from "@methanium/ui";
|
||||
import z from "zod";
|
||||
|
||||
import { useEffect, useRef, useState, type ReactNode } from "react";
|
||||
|
|
@ -43,12 +43,11 @@ import Crypto from "@tensamin/crypto/context";
|
|||
import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
|
||||
import { log } from "@tensamin/shared/log";
|
||||
|
||||
import LegalWrapper from "@/features/legal/screen";
|
||||
import CacheSync from "@tensamin/cache/sync";
|
||||
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useLocation, useNavigate } from "@tanstack/react-router";
|
||||
import { useIsMobile, Toaster, TooltipProvider } from "@tensamin/ui";
|
||||
import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
const wrapper = document.getElementById("root");
|
||||
|
|
@ -131,6 +130,11 @@ function ThemeStorageBridge() {
|
|||
setThemeBorderRadius,
|
||||
themeCustomCss,
|
||||
setThemeCustomCss,
|
||||
parentThemeId,
|
||||
setParentThemeId,
|
||||
applyThemePreset,
|
||||
themeDesign,
|
||||
setThemeDesign,
|
||||
} = useTheme();
|
||||
const loadedRef = useRef(false);
|
||||
|
||||
|
|
@ -145,6 +149,8 @@ function ThemeStorageBridge() {
|
|||
load("theme_tint"),
|
||||
load("theme_border_radius"),
|
||||
load("theme_custom_css"),
|
||||
load("theme_parent"),
|
||||
load("theme_design"),
|
||||
]).then(
|
||||
([
|
||||
color,
|
||||
|
|
@ -154,18 +160,23 @@ function ThemeStorageBridge() {
|
|||
tint,
|
||||
borderRadius,
|
||||
customCss,
|
||||
parent,
|
||||
design,
|
||||
]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (customCss === "" && parent) applyThemePreset(parent);
|
||||
else setParentThemeId(parent || null);
|
||||
setThemeColor(color);
|
||||
setThemePalette(palette);
|
||||
setThemePrimaryColor(primaryColor);
|
||||
setThemePolarity(polarity);
|
||||
setThemeTint(tint);
|
||||
setThemeBorderRadius(borderRadius);
|
||||
setThemeCustomCss(customCss);
|
||||
if (customCss !== "") setThemeCustomCss(customCss);
|
||||
setThemeDesign(design);
|
||||
loadedRef.current = true;
|
||||
},
|
||||
);
|
||||
|
|
@ -175,9 +186,12 @@ function ThemeStorageBridge() {
|
|||
};
|
||||
}, [
|
||||
load,
|
||||
applyThemePreset,
|
||||
setThemeBorderRadius,
|
||||
setThemeColor,
|
||||
setThemeCustomCss,
|
||||
setParentThemeId,
|
||||
setThemeDesign,
|
||||
setThemePalette,
|
||||
setThemePolarity,
|
||||
setThemePrimaryColor,
|
||||
|
|
@ -212,6 +226,14 @@ function ThemeStorageBridge() {
|
|||
if (loadedRef.current) save("theme_custom_css", 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;
|
||||
}
|
||||
|
||||
|
|
@ -228,6 +250,8 @@ function RootShell() {
|
|||
tintStorageKey={null}
|
||||
borderRadiusStorageKey={null}
|
||||
customCssStorageKey={null}
|
||||
parentThemeStorageKey={null}
|
||||
designStorageKey={null}
|
||||
>
|
||||
<div className="w-screen h-dvh overflow-hidden">
|
||||
<Toaster
|
||||
|
|
@ -244,13 +268,7 @@ function RootShell() {
|
|||
<Storage>
|
||||
<ThemeStorageBridge />
|
||||
<LoginWrapper>
|
||||
<LegalWrapper>
|
||||
<Crypto>
|
||||
<DesktopMediaProvider>
|
||||
<Outlet />
|
||||
</DesktopMediaProvider>
|
||||
</Crypto>
|
||||
</LegalWrapper>
|
||||
<Outlet />
|
||||
</LoginWrapper>
|
||||
</Storage>
|
||||
</TooltipProvider>
|
||||
|
|
@ -262,24 +280,29 @@ function RootShell() {
|
|||
|
||||
function AppShell() {
|
||||
return (
|
||||
<MTPProvider>
|
||||
<CacheSync />
|
||||
<Session>
|
||||
<UserProvider>
|
||||
<CallInit />
|
||||
<CallPopout />
|
||||
<TAuthWrapper>
|
||||
<AppLayout>
|
||||
<ChatContext>
|
||||
<NotificationsProvider>
|
||||
<Outlet />
|
||||
</NotificationsProvider>
|
||||
</ChatContext>
|
||||
</AppLayout>
|
||||
</TAuthWrapper>
|
||||
</UserProvider>
|
||||
</Session>
|
||||
</MTPProvider>
|
||||
<OnboardingGate>
|
||||
<Crypto>
|
||||
<DesktopMediaProvider>
|
||||
<MTPProvider>
|
||||
<CacheSync />
|
||||
<Session>
|
||||
<UserProvider>
|
||||
<CallInit />
|
||||
<TAuthWrapper>
|
||||
<AppLayout>
|
||||
<ChatContext>
|
||||
<NotificationsProvider>
|
||||
<Outlet />
|
||||
</NotificationsProvider>
|
||||
</ChatContext>
|
||||
</AppLayout>
|
||||
</TAuthWrapper>
|
||||
</UserProvider>
|
||||
</Session>
|
||||
</MTPProvider>
|
||||
</DesktopMediaProvider>
|
||||
</Crypto>
|
||||
</OnboardingGate>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -307,7 +330,7 @@ function createCallTrayIcon(color: string, speaking: boolean) {
|
|||
}
|
||||
|
||||
function CallInit() {
|
||||
useInitializeCall();
|
||||
const callInvitePopup = useInitializeCall();
|
||||
|
||||
const { load } = useStorage();
|
||||
const {
|
||||
|
|
@ -370,7 +393,7 @@ function CallInit() {
|
|||
});
|
||||
}, [inCall, primaryColor, speaking]);
|
||||
|
||||
return null;
|
||||
return callInvitePopup;
|
||||
}
|
||||
|
||||
const rootRoute = createRootRoute({
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
Input,
|
||||
Button,
|
||||
useIsMobile,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import z from "zod";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import { useState } from "react";
|
||||
|
|
@ -79,7 +79,7 @@ function AddConversationButton() {
|
|||
Username: result.data,
|
||||
})
|
||||
.then((data) => {
|
||||
if (data.data.UserId === 0) {
|
||||
if (data.type === "ErrorNotFound" || data.data.UserId === 0) {
|
||||
throw new Error();
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +101,7 @@ function AddConversationButton() {
|
|||
const timeout = setTimeout(() => setLoading(true), 500);
|
||||
|
||||
send("AddConversation", {
|
||||
ChatPartnerName: result.data,
|
||||
ChatPartnerId: user.data.UserId,
|
||||
})
|
||||
.then(() => {
|
||||
insertContact(user.data.UserId);
|
||||
|
|
@ -131,8 +131,12 @@ function AddConversationButton() {
|
|||
setOpen(value);
|
||||
}}
|
||||
>
|
||||
<DialogTrigger render={<Button>Add Conversation</Button>} />
|
||||
<DialogContent>
|
||||
<DialogTrigger
|
||||
render={({ onClick }) => (
|
||||
<Button onClick={onClick}>Add Conversation</Button>
|
||||
)}
|
||||
/>
|
||||
<DialogContent showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>New Conversation</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
|
@ -160,7 +164,13 @@ function AddConversationButton() {
|
|||
{error && (
|
||||
<p className="text-sm text-destructive w-full">{error}</p>
|
||||
)}
|
||||
<DialogClose render={<Button variant="outline">Cancel</Button>} />
|
||||
<DialogClose
|
||||
render={({ onClick }) => (
|
||||
<Button onClick={onClick} variant="outline">
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<Button disabled={loading} type="submit">
|
||||
{loading && <Loader2 className="animate-spin" />} Continue
|
||||
</Button>
|
||||
|
|
|
|||
|
|
@ -3,8 +3,9 @@ import { type ReactNode } from "react";
|
|||
import Sidebar from "@/components/sidebar";
|
||||
import Navbar, { MobileNavbar } from "@/components/navbar";
|
||||
import { useShowMobileNavbar } from "./useShowMobileNavbar";
|
||||
import CallPopout from "@tensamin/call/popout";
|
||||
|
||||
import { useIsMobile, cn, SidebarProvider } from "@tensamin/ui";
|
||||
import { useIsMobile, cn, SidebarProvider } from "@methanium/ui";
|
||||
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
|
||||
|
|
@ -16,6 +17,7 @@ export default function Layout({ children }: { children: ReactNode }) {
|
|||
<div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
|
||||
<SidebarProvider className="h-full min-h-0 overflow-hidden">
|
||||
<Sidebar />
|
||||
<CallPopout />
|
||||
<div
|
||||
// Background of ui that is overlapping with the system ui
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import Form from "@/components/screens/login/form";
|
||||
import { CreateScreen } from "@tensamin/ui";
|
||||
import { CreateScreen } from "@methanium/ui";
|
||||
|
||||
/**
|
||||
* Executes Page.
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { defineConfig, type Plugin } from "vite";
|
|||
import react from "@vitejs/plugin-react";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { mtp } from "mtp/vite";
|
||||
import { methaniumUi } from "@methanium/ui/vite";
|
||||
|
||||
const host = process.env.TAURI_DEV_HOST;
|
||||
const appDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
|
@ -105,7 +106,7 @@ export default defineConfig({
|
|||
? {
|
||||
protocol: "ws",
|
||||
host,
|
||||
port: 1421,
|
||||
clientPort: 3000,
|
||||
}
|
||||
: undefined,
|
||||
watch: {
|
||||
|
|
@ -136,6 +137,7 @@ export default defineConfig({
|
|||
"@tensamin/markdown",
|
||||
"@tensamin/mtp",
|
||||
"@tensamin/notifications",
|
||||
"@tensamin/onboarding",
|
||||
"@tensamin/shared",
|
||||
"@tensamin/shared/data",
|
||||
"@tensamin/shared/log",
|
||||
|
|
@ -152,8 +154,9 @@ export default defineConfig({
|
|||
sourcemap: !!process.env.TAURI_ENV_DEBUG,
|
||||
},
|
||||
plugins: [
|
||||
methaniumUi({ defaultThemeId: "tensamin" }),
|
||||
deepFilterAssetHeaders(resolve(appDir, "public")),
|
||||
mtp({ typeMaps: resolve(appDir, "../../type-maps.yaml") }),
|
||||
mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),
|
||||
{
|
||||
name: "workspace-realpath-resolution",
|
||||
enforce: "post",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ import reactHooks from "eslint-plugin-react-hooks";
|
|||
import * as tsParser from "@typescript-eslint/parser";
|
||||
import { dirname } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
noReactNamespaceImport,
|
||||
noWindowLocationReload,
|
||||
} from "./utils/eslint-rules/index.js";
|
||||
|
||||
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
|
||||
|
||||
|
|
@ -28,10 +32,18 @@ export default [
|
|||
},
|
||||
plugins: {
|
||||
"react-hooks": reactHooks,
|
||||
tensamin: {
|
||||
rules: {
|
||||
"no-react-namespace-import": noReactNamespaceImport,
|
||||
"no-window-location-reload": noWindowLocationReload,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
"react-hooks/set-state-in-effect": "off",
|
||||
"tensamin/no-react-namespace-import": "error",
|
||||
"tensamin/no-window-location-reload": "error",
|
||||
},
|
||||
},
|
||||
];
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@
|
|||
systems = [ "x86_64-linux" ];
|
||||
forAllSystems = nixpkgs.lib.genAttrs systems;
|
||||
version = "0.0.10";
|
||||
x86_64DebHash = "sha256-VVKkZ9yQ7BoLnYnCf5OSHcrakoBNfUx/uWxBmm3gBZE=";
|
||||
x86_64DebHash = "sha256-TK7XmPWFbAVwlrE8hX8x+QsGFa0FTH9Mctmxu9JsHb4=";
|
||||
forgejoBaseUrl = "https://git.methanium.net/tensamin/client/releases/download/${version}";
|
||||
in
|
||||
{
|
||||
|
|
@ -53,6 +53,7 @@
|
|||
libglvnd
|
||||
libnotify
|
||||
libpulseaudio
|
||||
libsecret
|
||||
libuuid
|
||||
libxkbcommon
|
||||
mesa
|
||||
|
|
@ -104,7 +105,8 @@
|
|||
cp -r usr/* "$out/"
|
||||
|
||||
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" \
|
||||
--replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin"
|
||||
|
|
@ -172,6 +174,7 @@
|
|||
libglvnd
|
||||
libnotify
|
||||
libpulseaudio
|
||||
libsecret
|
||||
libuuid
|
||||
libxkbcommon
|
||||
mesa
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
The MIT License (MIT)
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Paul Miller (https://paulmillr.com)
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
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
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
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
|
||||
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,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
|
|
@ -1,9 +1,9 @@
|
|||
The MIT License (MIT)
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2022 Paul Miller (https://paulmillr.com)
|
||||
Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
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
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
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
|
||||
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,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
22
licenses/@twemoji_api@17.0.3/LICENSE
Normal file
22
licenses/@twemoji_api@17.0.3/LICENSE
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2022–present Jason Sofonia & Justine De Caires
|
||||
Copyright (c) 2014–2021 Twitter
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -31,6 +31,15 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
|||
- Folder: `licenses/@base-ui_utils@0.3.1`
|
||||
- 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
|
||||
|
||||
- License: MIT
|
||||
|
|
@ -49,6 +58,15 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
|||
- Folder: `licenses/@codemirror_lang-markdown@6.5.0`
|
||||
- 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
|
||||
|
||||
- License: MIT
|
||||
|
|
@ -145,26 +163,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
|||
- Folder: `licenses/@livekit_components-react@2.9.21`
|
||||
- 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
|
||||
|
||||
- License: MIT
|
||||
|
|
@ -494,13 +492,23 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
|||
- Folder: `licenses/@tauri-apps_plugin-notification@2.3.3`
|
||||
- Source package dir: `apps/tauri/node_modules/@tauri-apps/plugin-notification`
|
||||
|
||||
## @tensamin/ui@0.0.39
|
||||
## @tensamin/ui@0.0.41
|
||||
|
||||
- License: UNKNOWN
|
||||
- Included files: none found
|
||||
- Folder: `licenses/@tensamin_ui@0.0.39`
|
||||
- Folder: `licenses/@tensamin_ui@0.0.41`
|
||||
- Source package dir: `apps/tauri/node_modules/@tensamin/ui`
|
||||
|
||||
## @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
|
||||
|
||||
- License: MIT
|
||||
|
|
@ -599,15 +607,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
|||
- Folder: `licenses/cmdk@1.1.1`
|
||||
- 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
|
||||
|
||||
- License: MIT
|
||||
|
|
@ -823,6 +822,15 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
|||
- Folder: `licenses/embla-carousel-reactive-utils@8.6.0`
|
||||
- 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
|
||||
|
||||
- License: MIT
|
||||
|
|
@ -958,12 +966,21 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
|
|||
- Folder: `licenses/lucide-react@1.23.0`
|
||||
- Source package dir: `apps/web/node_modules/lucide-react`
|
||||
|
||||
## mtp@0.1.0
|
||||
## motion@12.42.2
|
||||
|
||||
- License: MIT
|
||||
- Repository: https://github.com/motiondivision/motion
|
||||
- Description: An animation library for JavaScript and React.
|
||||
- Included files: LICENSE.md
|
||||
- Folder: `licenses/motion@12.42.2`
|
||||
- Source package dir: `packages/chat/node_modules/motion`
|
||||
|
||||
## mtp@0.2.0
|
||||
|
||||
- License: UNKNOWN
|
||||
- Description: MTP TypeScript SDK
|
||||
- Included files: none found
|
||||
- Folder: `licenses/mtp@0.1.0`
|
||||
- Folder: `licenses/mtp@0.2.0`
|
||||
- Source package dir: `packages/mtp/node_modules/mtp`
|
||||
|
||||
## next-themes@0.4.6
|
||||
|
|
|
|||
|
|
@ -1,202 +0,0 @@
|
|||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright 2017 Google Inc.
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
21
licenses/emojibase-data@17.0.0/LICENSE
Normal file
21
licenses/emojibase-data@17.0.0/LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2017-2019 Miles Johnson
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
21
licenses/motion@12.42.2/LICENSE.md
Normal file
21
licenses/motion@12.42.2/LICENSE.md
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
The MIT License (MIT)
|
||||
|
||||
Copyright (c) 2024 [Motion](https://motion.dev) B.V.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
"specVersion": "1.5",
|
||||
"version": 1,
|
||||
"metadata": {
|
||||
"timestamp": "2026-07-03T19:50:00.640Z",
|
||||
"timestamp": "2026-07-24T10:43:11.914Z",
|
||||
"tools": [
|
||||
{
|
||||
"vendor": "OpenAI",
|
||||
|
|
@ -117,6 +117,37 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/%40codemirror/autocomplete@6.20.3",
|
||||
"name": "@codemirror/autocomplete",
|
||||
"version": "6.20.3",
|
||||
"purl": "pkg:npm/%40codemirror/autocomplete@6.20.3",
|
||||
"description": "Autocompletion for the CodeMirror code editor",
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git+https://code.haverbeke.berlin/codemirror/autocomplete.git"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "local:licenseFolder",
|
||||
"value": "licenses/@codemirror_autocomplete@6.20.3"
|
||||
},
|
||||
{
|
||||
"name": "local:sourcePackageDir",
|
||||
"value": "packages/markdown/node_modules/@codemirror/autocomplete"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/%40codemirror/commands@6.10.4",
|
||||
|
|
@ -179,6 +210,37 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/%40codemirror/language@6.12.4",
|
||||
"name": "@codemirror/language",
|
||||
"version": "6.12.4",
|
||||
"purl": "pkg:npm/%40codemirror/language@6.12.4",
|
||||
"description": "Language support infrastructure for the CodeMirror code editor",
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git+https://code.haverbeke.berlin/codemirror/language.git"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "local:licenseFolder",
|
||||
"value": "licenses/@codemirror_language@6.12.4"
|
||||
},
|
||||
{
|
||||
"name": "local:sourcePackageDir",
|
||||
"value": "packages/markdown/node_modules/@codemirror/language"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/%40codemirror/state@6.7.0",
|
||||
|
|
@ -516,76 +578,6 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/%40noble/curves@2.2.0",
|
||||
"name": "@noble/curves",
|
||||
"version": "2.2.0",
|
||||
"purl": "pkg:npm/%40noble/curves@2.2.0",
|
||||
"description": "Audited & minimal JS implementation of elliptic curve cryptography",
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://paulmillr.com/noble/"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git+https://github.com/paulmillr/noble-curves.git"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "local:licenseFolder",
|
||||
"value": "licenses/@noble_curves@2.2.0"
|
||||
},
|
||||
{
|
||||
"name": "local:sourcePackageDir",
|
||||
"value": "packages/crypto/node_modules/@noble/curves"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/%40noble/hashes@2.2.0",
|
||||
"name": "@noble/hashes",
|
||||
"version": "2.2.0",
|
||||
"purl": "pkg:npm/%40noble/hashes@2.2.0",
|
||||
"description": "Audited & minimal 0-dependency JS implementation of SHA, RIPEMD, BLAKE, HMAC, HKDF, PBKDF & Scrypt",
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://paulmillr.com/noble/"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git+https://github.com/paulmillr/noble-hashes.git"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "local:licenseFolder",
|
||||
"value": "licenses/@noble_hashes@2.2.0"
|
||||
},
|
||||
{
|
||||
"name": "local:sourcePackageDir",
|
||||
"value": "packages/crypto/node_modules/@noble/hashes"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/%40radix-ui/primitive@1.1.4",
|
||||
|
|
@ -1780,15 +1772,15 @@
|
|||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/%40tensamin/ui@0.0.39",
|
||||
"bomRef": "pkg:npm/%40tensamin/ui@0.0.41",
|
||||
"name": "@tensamin/ui",
|
||||
"version": "0.0.39",
|
||||
"purl": "pkg:npm/%40tensamin/ui@0.0.39",
|
||||
"version": "0.0.41",
|
||||
"purl": "pkg:npm/%40tensamin/ui@0.0.41",
|
||||
"externalReferences": [],
|
||||
"properties": [
|
||||
{
|
||||
"name": "local:licenseFolder",
|
||||
"value": "licenses/@tensamin_ui@0.0.39"
|
||||
"value": "licenses/@tensamin_ui@0.0.41"
|
||||
},
|
||||
{
|
||||
"name": "local:sourcePackageDir",
|
||||
|
|
@ -1796,6 +1788,41 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/%40twemoji/api@17.0.3",
|
||||
"name": "@twemoji/api",
|
||||
"version": "17.0.3",
|
||||
"purl": "pkg:npm/%40twemoji/api@17.0.3",
|
||||
"description": "A Unicode standard based way to implement emoji across all platforms.",
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"name": "MIT AND CC-BY-4.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "website",
|
||||
"url": "https://github.com/jdecked/twemoji"
|
||||
},
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git://github.com/jdecked/twemoji.git"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "local:licenseFolder",
|
||||
"value": "licenses/@twemoji_api@17.0.3"
|
||||
},
|
||||
{
|
||||
"name": "local:sourcePackageDir",
|
||||
"value": "packages/markdown/node_modules/@twemoji/api"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/%40types/node@25.9.4",
|
||||
|
|
@ -2141,37 +2168,6 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/comlink@4.4.2",
|
||||
"name": "comlink",
|
||||
"version": "4.4.2",
|
||||
"purl": "pkg:npm/comlink@4.4.2",
|
||||
"description": "Comlink makes WebWorkers enjoyable",
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "Apache-2.0"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/GoogleChromeLabs/comlink.git"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "local:licenseFolder",
|
||||
"value": "licenses/comlink@4.4.2"
|
||||
},
|
||||
{
|
||||
"name": "local:sourcePackageDir",
|
||||
"value": "packages/crypto/node_modules/comlink"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/cookie-es@3.1.1",
|
||||
|
|
@ -2925,6 +2921,37 @@
|
|||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/emojibase-data@17.0.0",
|
||||
"name": "emojibase-data",
|
||||
"version": "17.0.0",
|
||||
"purl": "pkg:npm/emojibase-data@17.0.0",
|
||||
"description": "Evergreen emoji datasets.",
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "git@github.com:milesj/emojibase.git"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "local:licenseFolder",
|
||||
"value": "licenses/emojibase-data@17.0.0"
|
||||
},
|
||||
{
|
||||
"name": "local:sourcePackageDir",
|
||||
"value": "packages/markdown/node_modules/emojibase-data"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/es-toolkit@1.49.0",
|
||||
|
|
@ -3397,16 +3424,47 @@
|
|||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/mtp@0.1.0",
|
||||
"bomRef": "pkg:npm/motion@12.42.2",
|
||||
"name": "motion",
|
||||
"version": "12.42.2",
|
||||
"purl": "pkg:npm/motion@12.42.2",
|
||||
"description": "An animation library for JavaScript and React.",
|
||||
"licenses": [
|
||||
{
|
||||
"license": {
|
||||
"id": "MIT"
|
||||
}
|
||||
}
|
||||
],
|
||||
"externalReferences": [
|
||||
{
|
||||
"type": "vcs",
|
||||
"url": "https://github.com/motiondivision/motion"
|
||||
}
|
||||
],
|
||||
"properties": [
|
||||
{
|
||||
"name": "local:licenseFolder",
|
||||
"value": "licenses/motion@12.42.2"
|
||||
},
|
||||
{
|
||||
"name": "local:sourcePackageDir",
|
||||
"value": "packages/chat/node_modules/motion"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"type": "library",
|
||||
"bomRef": "pkg:npm/mtp@0.2.0",
|
||||
"name": "mtp",
|
||||
"version": "0.1.0",
|
||||
"purl": "pkg:npm/mtp@0.1.0",
|
||||
"version": "0.2.0",
|
||||
"purl": "pkg:npm/mtp@0.2.0",
|
||||
"description": "MTP TypeScript SDK",
|
||||
"externalReferences": [],
|
||||
"properties": [
|
||||
{
|
||||
"name": "local:licenseFolder",
|
||||
"value": "licenses/mtp@0.1.0"
|
||||
"value": "licenses/mtp@0.2.0"
|
||||
},
|
||||
{
|
||||
"name": "local:sourcePackageDir",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"generatedAt": "2026-07-03T19:50:00.637Z",
|
||||
"packageCount": 143,
|
||||
"generatedAt": "2026-07-24T10:43:11.913Z",
|
||||
"packageCount": 145,
|
||||
"packages": [
|
||||
{
|
||||
"name": "@babel/runtime",
|
||||
|
|
@ -41,6 +41,19 @@
|
|||
"licenseFolder": "licenses/@base-ui_utils@0.3.1",
|
||||
"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",
|
||||
"version": "6.10.4",
|
||||
|
|
@ -67,6 +80,19 @@
|
|||
"licenseFolder": "licenses/@codemirror_lang-markdown@6.5.0",
|
||||
"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",
|
||||
"version": "6.7.0",
|
||||
|
|
@ -197,32 +223,6 @@
|
|||
"licenseFolder": "licenses/@livekit_components-react@2.9.21",
|
||||
"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",
|
||||
"version": "1.1.4",
|
||||
|
|
@ -368,7 +368,7 @@
|
|||
},
|
||||
{
|
||||
"name": "@radix-ui/react-slot",
|
||||
"version": "1.2.1",
|
||||
"version": "1.3.0",
|
||||
"license": "MIT",
|
||||
"homepage": "https://radix-ui.com/primitives",
|
||||
"repository": "git+https://github.com/radix-ui/primitives.git",
|
||||
|
|
@ -682,15 +682,28 @@
|
|||
},
|
||||
{
|
||||
"name": "@tensamin/ui",
|
||||
"version": "0.0.39",
|
||||
"version": "0.0.41",
|
||||
"license": "UNKNOWN",
|
||||
"homepage": null,
|
||||
"repository": null,
|
||||
"description": null,
|
||||
"files": [],
|
||||
"licenseFolder": "licenses/@tensamin_ui@0.0.39",
|
||||
"licenseFolder": "licenses/@tensamin_ui@0.0.41",
|
||||
"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",
|
||||
"version": "25.9.4",
|
||||
|
|
@ -821,19 +834,6 @@
|
|||
"licenseFolder": "licenses/cmdk@1.1.1",
|
||||
"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",
|
||||
"version": "3.1.1",
|
||||
|
|
@ -1018,7 +1018,7 @@
|
|||
},
|
||||
{
|
||||
"name": "deepfilternet3-noise-filter",
|
||||
"version": "1.3.0",
|
||||
"version": "1.2.1",
|
||||
"license": "(Apache-2.0 OR MIT)",
|
||||
"homepage": "https://github.com/mezonai/mezon-noise-suppression#readme",
|
||||
"repository": "git+https://github.com/mezonai/mezon-noise-suppression.git",
|
||||
|
|
@ -1115,6 +1115,19 @@
|
|||
"licenseFolder": "licenses/embla-carousel-reactive-utils@8.6.0",
|
||||
"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",
|
||||
"version": "1.49.0",
|
||||
|
|
@ -1293,15 +1306,28 @@
|
|||
"licenseFolder": "licenses/lucide-react@1.23.0",
|
||||
"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",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "UNKNOWN",
|
||||
"homepage": null,
|
||||
"repository": null,
|
||||
"description": "MTP TypeScript SDK",
|
||||
"files": [],
|
||||
"licenseFolder": "licenses/mtp@0.1.0",
|
||||
"licenseFolder": "licenses/mtp@0.2.0",
|
||||
"sourcePackageDir": "packages/mtp/node_modules/mtp"
|
||||
},
|
||||
{
|
||||
|
|
|
|||
1
mtp-type-maps
Submodule
1
mtp-type-maps
Submodule
|
|
@ -0,0 +1 @@
|
|||
Subproject commit 11a1d79409857b734e948dd8c3e28e6ba721d15f
|
||||
16
package.json
16
package.json
|
|
@ -10,15 +10,15 @@
|
|||
"type": "module",
|
||||
"scripts": {
|
||||
"format": "pnpm exec prettier --write .",
|
||||
"lint": "node scripts/lint-packages.ts",
|
||||
"lint": "node utils/scripts/lint-packages.ts",
|
||||
"check": "fallow && pnpm run lint",
|
||||
"copy-licenses": "node scripts/copy-licenses.ts",
|
||||
"copy-releases": "node scripts/copy-releases.ts",
|
||||
"copy-licenses": "node utils/scripts/copy-licenses.ts",
|
||||
"copy-releases": "node utils/scripts/copy-releases.ts",
|
||||
"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:packages": "node scripts/build-packages.ts",
|
||||
"update:packages": "node scripts/update-packages.ts",
|
||||
"dev:web": "cd apps/web && pnpm dev",
|
||||
"build:packages": "node utils/scripts/build-packages.ts",
|
||||
"update:packages": "node utils/scripts/update-packages.ts",
|
||||
"dev": "cd apps/web && pnpm dev",
|
||||
"build:web": "cd apps/web && pnpm run build",
|
||||
"preview:web": "cd apps/web && pnpm run preview",
|
||||
"dev:mobile": "cd apps/tauri && pnpm dev:mobile",
|
||||
|
|
@ -26,7 +26,7 @@
|
|||
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
|
||||
"dev:desktop": "cd apps/electron && pnpm run dev",
|
||||
"build:desktop": "cd apps/electron && pnpm run package",
|
||||
"delete:mobile": "cd apps/tauri && nix develop ../..#tauri --command adb uninstall net.tensamin.client"
|
||||
"delete:mobile": "cd apps/tauri && nix develop ../..#tauri --command adb uninstall net.tensamin.client.dev"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^10.0.1",
|
||||
|
|
@ -46,7 +46,7 @@
|
|||
"yaml": "^2.8.2"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tensamin/ui": "*",
|
||||
"@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.2/methanium-ui.tgz",
|
||||
"mtp": "*",
|
||||
"sonner": "^2.0.7"
|
||||
}
|
||||
|
|
|
|||
21
packages/cache/src/index.ts
vendored
21
packages/cache/src/index.ts
vendored
|
|
@ -175,12 +175,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
|
|||
const userId = Number(key);
|
||||
return retained.has(userId)
|
||||
? []
|
||||
: [
|
||||
deleteDatabaseEntry(
|
||||
"cache",
|
||||
storedKey("conversations", key),
|
||||
),
|
||||
];
|
||||
: [deleteDatabaseEntry("cache", storedKey("conversations", key))];
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
@ -210,12 +205,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
|
|||
const userId = Number(key);
|
||||
return retained.has(userId)
|
||||
? []
|
||||
: [
|
||||
deleteDatabaseEntry(
|
||||
"cache",
|
||||
storedKey("conversations", key),
|
||||
),
|
||||
];
|
||||
: [deleteDatabaseEntry("cache", storedKey("conversations", key))];
|
||||
}),
|
||||
);
|
||||
},
|
||||
|
|
@ -241,12 +231,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
|
|||
storedEntries.flatMap(([key]) =>
|
||||
retained.has(Number(key))
|
||||
? []
|
||||
: [
|
||||
deleteDatabaseEntry(
|
||||
"cache",
|
||||
storedKey("conversations", key),
|
||||
),
|
||||
],
|
||||
: [deleteDatabaseEntry("cache", storedKey("conversations", key))],
|
||||
),
|
||||
);
|
||||
},
|
||||
|
|
|
|||
42
packages/cache/src/sync.tsx
vendored
42
packages/cache/src/sync.tsx
vendored
|
|
@ -18,7 +18,6 @@ export default function CacheSync() {
|
|||
const { load } = useStorage();
|
||||
const [accountId, setAccountId] = useState(0);
|
||||
const queueRef = useRef(Promise.resolve());
|
||||
const reactionPartnersRef = useRef(new Map<number, number>());
|
||||
|
||||
useEffect(() => {
|
||||
void load("user_id").then(setAccountId);
|
||||
|
|
@ -143,6 +142,7 @@ export default function CacheSync() {
|
|||
MessageState: "sent",
|
||||
SenderId: accountId,
|
||||
SendTime: Number(request.SendTime),
|
||||
ReplyId: request.ReplyId ? Number(request.ReplyId) : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
|
@ -195,23 +195,6 @@ export default function CacheSync() {
|
|||
);
|
||||
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],
|
||||
);
|
||||
|
|
@ -259,13 +242,28 @@ export default function CacheSync() {
|
|||
return;
|
||||
}
|
||||
if (message.type === "MessageReactionLive") {
|
||||
reactionPartnersRef.current.set(
|
||||
Number(data.SendTime),
|
||||
Number(data.ChatPartnerId),
|
||||
const partnerId = Number(data.ChatPartnerId);
|
||||
const sendTime = Number(data.SendTime);
|
||||
const senderId = Number(data.SenderId);
|
||||
const reaction = String(data.Reaction);
|
||||
const cache = secureCache();
|
||||
const window = await cache.conversations.get(partnerId);
|
||||
const target = window?.Messages.find(
|
||||
(candidate) => candidate.SendTime === sendTime,
|
||||
);
|
||||
if (!window || !target) return;
|
||||
|
||||
const reactions = (target.Reactions ?? []).filter(
|
||||
(candidate) =>
|
||||
candidate.SenderId !== senderId || candidate.Reaction !== reaction,
|
||||
);
|
||||
if (data.Accepted === true) {
|
||||
reactions.push({ SenderId: senderId, Reaction: reaction });
|
||||
}
|
||||
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
|
||||
}
|
||||
},
|
||||
[accountId, insertMessage, removeMessage, replaceMessage],
|
||||
[accountId, insertMessage, removeMessage, replaceMessage, secureCache],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
|
|||
|
|
@ -19,12 +19,11 @@
|
|||
"dependencies": {
|
||||
"@livekit/components-react": "^2.9.20",
|
||||
"@tanstack/react-router": "^1.169.1",
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tensamin/crypto": "workspace:*",
|
||||
"@tensamin/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/mtp": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@methanium/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"deepfilternet3-noise-filter": "1.2.1",
|
||||
"livekit-client": "^2.18.8",
|
||||
|
|
|
|||
|
|
@ -5,11 +5,13 @@ import {
|
|||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@tensamin/ui";
|
||||
useIsMobile,
|
||||
cn,
|
||||
} from "@methanium/ui";
|
||||
import { useEffect, useState } from "react";
|
||||
import MuteButton from "./buttons/mute";
|
||||
import DeafButton from "./buttons/deaf";
|
||||
import ScreenshareButton from "./buttons/screenshare";
|
||||
import MediaShareButton from "./buttons/mediaShare";
|
||||
import LeaveButton from "./buttons/leave";
|
||||
import {
|
||||
setCallIsPopout,
|
||||
|
|
@ -31,7 +33,7 @@ import {
|
|||
import { useStorage } from "@tensamin/storage/context";
|
||||
|
||||
export default function Actions() {
|
||||
const sharedClasses = "w-14 h-10";
|
||||
const sharedClasses = "w-14! h-10!";
|
||||
const sharedIconSize = 15;
|
||||
const view = useCall((state) => state.view);
|
||||
const focusedParticipantId = useCall((state) => state.focusedParticipantId);
|
||||
|
|
@ -77,34 +79,45 @@ export default function Actions() {
|
|||
(state) => state.usersInFocusedViewHidden,
|
||||
);
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div className="w-full flex justify-between items-center">
|
||||
<div className="w-30 flex justify-start">
|
||||
{view === "focused" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
onClick={() =>
|
||||
setUsersInFocusedViewHidden(!usersInFocusedViewHidden)
|
||||
}
|
||||
variant="link"
|
||||
className="w-11 h-11 p-0! ml-3 text-foreground"
|
||||
>
|
||||
{usersInFocusedViewHidden ? (
|
||||
<ChevronUp className="size-6" />
|
||||
) : (
|
||||
<ChevronDown className="size-6" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
Hide users
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex items-center",
|
||||
isMobile ? "justify-center" : "justify-between",
|
||||
)}
|
||||
>
|
||||
{!isMobile && (
|
||||
<div className="w-30 flex justify-start">
|
||||
{view === "focused" && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={({ ref, onClick }) => (
|
||||
<Button
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
setUsersInFocusedViewHidden(!usersInFocusedViewHidden);
|
||||
}}
|
||||
variant="ghost"
|
||||
className="w-11 h-11! p-0! ml-3 text-foreground border-0!"
|
||||
>
|
||||
{usersInFocusedViewHidden ? (
|
||||
<ChevronUp className="size-6" />
|
||||
) : (
|
||||
<ChevronDown className="size-6" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
Hide users
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<Card className="p-1.75">
|
||||
<CardContent className="p-0! flex gap-1.75">
|
||||
<MuteButton
|
||||
|
|
@ -119,10 +132,10 @@ export default function Actions() {
|
|||
tooltip="Deafen"
|
||||
portalContainer={portalContainer}
|
||||
/>
|
||||
<ScreenshareButton
|
||||
<MediaShareButton
|
||||
className={sharedClasses}
|
||||
iconSize={sharedIconSize}
|
||||
tooltip="Screenshare"
|
||||
tooltip="Share media"
|
||||
/>
|
||||
<InviteButton
|
||||
className={sharedClasses}
|
||||
|
|
@ -132,11 +145,15 @@ export default function Actions() {
|
|||
{isWatchingFocusedStream && focusedParticipantId !== ownId ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
render={({ ref, onClick }) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
className={sharedClasses}
|
||||
variant="destructive"
|
||||
onClick={() => stopWatchingFocusedStream()}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
stopWatchingFocusedStream();
|
||||
}}
|
||||
>
|
||||
<ScreenShareOff
|
||||
style={{
|
||||
|
|
@ -145,7 +162,7 @@ export default function Actions() {
|
|||
}}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
Stop watching
|
||||
|
|
@ -161,50 +178,58 @@ export default function Actions() {
|
|||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
<div className="w-30 flex justify-end">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
onClick={() => setCallIsPopout(!callIsPopout)}
|
||||
variant="link"
|
||||
className="w-11 h-11 p-0! text-foreground"
|
||||
>
|
||||
{callIsPopout ? (
|
||||
<SquareArrowOutDownLeft className="size-6" />
|
||||
) : (
|
||||
<SquareArrowOutUpRight className="size-6" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
Popout
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
onClick={() => {
|
||||
void toggleFullscreen();
|
||||
}}
|
||||
variant="link"
|
||||
className="w-11 h-11 p-0! mr-3 text-foreground"
|
||||
>
|
||||
{callIsFullscreen ? (
|
||||
<Minimize className="size-6" />
|
||||
) : (
|
||||
<Maximize className="size-6" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
Fullscreen
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div className="w-30 flex justify-end gap-1.5">
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={({ ref, onClick }) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
setCallIsPopout(!callIsPopout);
|
||||
}}
|
||||
variant="ghost"
|
||||
className="w-11 h-11! p-0! text-foreground border-0!"
|
||||
>
|
||||
{callIsPopout ? (
|
||||
<SquareArrowOutDownLeft className="size-6" />
|
||||
) : (
|
||||
<SquareArrowOutUpRight className="size-6" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
Popout
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={({ ref, onClick }) => (
|
||||
<Button
|
||||
ref={ref}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
void toggleFullscreen();
|
||||
}}
|
||||
variant="ghost"
|
||||
className="w-11 h-11! p-0! mr-3 text-foreground border-0!"
|
||||
>
|
||||
{callIsFullscreen ? (
|
||||
<Minimize className="size-6" />
|
||||
) : (
|
||||
<Maximize className="size-6" />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
Fullscreen
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { Button } from "@tensamin/ui";
|
||||
import { Button } from "@methanium/ui";
|
||||
import { toggleDeaf, useCall } from "../../store";
|
||||
import { HeadphoneOff, Headphones } from "lucide-react";
|
||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||
import {
|
||||
type CallButtonProps,
|
||||
iconScale,
|
||||
withButtonTooltip,
|
||||
} from "./tooltipButton";
|
||||
|
||||
export default function DeafButton({
|
||||
className,
|
||||
|
|
@ -25,5 +29,5 @@ export default function DeafButton({
|
|||
</Button>
|
||||
);
|
||||
|
||||
return withTooltip(button, tooltip, portalContainer);
|
||||
return withButtonTooltip(button, tooltip, portalContainer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
import { Mail } from "lucide-react";
|
||||
import { sendCallInvite, useCall } from "../../store";
|
||||
|
|
@ -38,13 +38,13 @@ export default function InviteButton({
|
|||
render={
|
||||
tooltip ? (
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button className={className}>
|
||||
render={({ ref, onClick }) => (
|
||||
<Button ref={ref} onClick={onClick} className={className}>
|
||||
<Mail
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Button className={className}>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { LeaveIcon } from "@livekit/components-react";
|
||||
import { Button } from "@tensamin/ui";
|
||||
import { Button } from "@methanium/ui";
|
||||
import { disconnect, useCall } from "../../store";
|
||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||
import {
|
||||
type CallButtonProps,
|
||||
iconScale,
|
||||
withButtonTooltip,
|
||||
} from "./tooltipButton";
|
||||
|
||||
export default function LeaveButton({
|
||||
className,
|
||||
|
|
@ -22,5 +26,5 @@ export default function LeaveButton({
|
|||
</Button>
|
||||
);
|
||||
|
||||
return withTooltip(button, tooltip, portalContainer);
|
||||
return withButtonTooltip(button, tooltip, portalContainer);
|
||||
}
|
||||
|
|
|
|||
171
packages/call/src/components/buttons/mediaShare.tsx
Normal file
171
packages/call/src/components/buttons/mediaShare.tsx
Normal file
|
|
@ -0,0 +1,171 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@methanium/ui";
|
||||
import { MonitorDot, ScreenShare } from "lucide-react";
|
||||
import { toast } from "@tensamin/shared/log";
|
||||
import {
|
||||
startScreenShare,
|
||||
stopCameraShare,
|
||||
stopScreenShare,
|
||||
useCall,
|
||||
} from "../../store";
|
||||
import { getMediaShareAdapter, type MediaShareKind } from "../../mediaShare";
|
||||
import MediaShareDialog from "../mediaShareDialog";
|
||||
|
||||
export default function MediaShareButton({
|
||||
className,
|
||||
iconSize,
|
||||
tooltip,
|
||||
defaultPortal,
|
||||
}: {
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
tooltip?: string;
|
||||
defaultPortal?: boolean;
|
||||
}) {
|
||||
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
||||
const cameraEnabled = useCall((state) => state.cameraEnabled);
|
||||
const screenRef = useCall((state) => state.screenRef);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
|
||||
const [dialogKind, setDialogKind] = useState<MediaShareKind | null>(null);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!defaultPortal) setPortalContainer(screenRef?.current ?? undefined);
|
||||
}, [screenRef, defaultPortal]);
|
||||
|
||||
async function beginScreenShare() {
|
||||
try {
|
||||
const capabilities = await getMediaShareAdapter().getCapabilities();
|
||||
if (capabilities.screenPicker === "sources") {
|
||||
setDialogKind("screen");
|
||||
} else {
|
||||
await startScreenShare({ includeAudio: true });
|
||||
}
|
||||
} catch (error) {
|
||||
console.error("Failed to start screen sharing", error);
|
||||
toast(
|
||||
"error",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to start screen sharing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function stop(kind: MediaShareKind) {
|
||||
try {
|
||||
await (kind === "screen" ? stopScreenShare() : stopCameraShare());
|
||||
} catch (error) {
|
||||
console.error(`Failed to stop ${kind} sharing`, error);
|
||||
toast("error", `Failed to stop ${kind} sharing.`);
|
||||
}
|
||||
}
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
variant={isScreensharing || cameraEnabled ? "subtleDefault" : "default"}
|
||||
className="w-full! h-full!"
|
||||
>
|
||||
{isScreensharing || cameraEnabled ? (
|
||||
<MonitorDot
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenShare
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<Popover onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<PopoverTrigger
|
||||
render={({ onClick }) =>
|
||||
tooltip ? (
|
||||
<TooltipTrigger
|
||||
render={({ ref }) => (
|
||||
<span
|
||||
ref={ref as React.Ref<HTMLSpanElement>}
|
||||
onClick={onClick}
|
||||
className={className}
|
||||
>
|
||||
{trigger}
|
||||
</span>
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<span className={className} onClick={onClick}>
|
||||
{trigger}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
className="flex w-48 flex-col gap-2"
|
||||
portalProps={{
|
||||
container: defaultPortal ? undefined : portalContainer,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
disabled={isScreensharing}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
void beginScreenShare();
|
||||
}}
|
||||
>
|
||||
Share screen
|
||||
</Button>
|
||||
<Button
|
||||
disabled={cameraEnabled}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
setDialogKind("camera");
|
||||
}}
|
||||
>
|
||||
Share camera
|
||||
</Button>
|
||||
{isScreensharing ? (
|
||||
<Button variant="destructive" onClick={() => void stop("screen")}>
|
||||
Stop screen sharing
|
||||
</Button>
|
||||
) : null}
|
||||
{cameraEnabled ? (
|
||||
<Button variant="destructive" onClick={() => void stop("camera")}>
|
||||
Stop camera
|
||||
</Button>
|
||||
) : null}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{tooltip ? (
|
||||
<TooltipContent
|
||||
portalProps={{
|
||||
container: defaultPortal ? undefined : portalContainer,
|
||||
}}
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
) : null}
|
||||
</Tooltip>
|
||||
|
||||
{dialogKind ? (
|
||||
<MediaShareDialog
|
||||
kind={dialogKind}
|
||||
open
|
||||
onOpenChange={(open) => !open && setDialogKind(null)}
|
||||
portalContainer={portalContainer}
|
||||
/>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,7 +1,11 @@
|
|||
import { Button } from "@tensamin/ui";
|
||||
import { Button } from "@methanium/ui";
|
||||
import { toggleMute, useCall } from "../../store";
|
||||
import { Mic, MicOff } from "lucide-react";
|
||||
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton";
|
||||
import {
|
||||
type CallButtonProps,
|
||||
iconScale,
|
||||
withButtonTooltip,
|
||||
} from "./tooltipButton";
|
||||
|
||||
export default function MuteButton({
|
||||
className,
|
||||
|
|
@ -25,5 +29,5 @@ export default function MuteButton({
|
|||
</Button>
|
||||
);
|
||||
|
||||
return withTooltip(button, tooltip, portalContainer);
|
||||
return withButtonTooltip(button, tooltip, portalContainer);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,169 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@tensamin/ui";
|
||||
import { MonitorDot, ScreenShare } from "lucide-react";
|
||||
import { toast } from "@tensamin/shared/log";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { setScreenShareEnabled, useCall } from "../../store";
|
||||
import ScreenShareDialog from "../screenshareDialog";
|
||||
|
||||
export default function ScreenshareButton({
|
||||
className,
|
||||
iconSize,
|
||||
tooltip,
|
||||
defaultPortal,
|
||||
}: {
|
||||
className?: string;
|
||||
iconSize?: number;
|
||||
tooltip?: string;
|
||||
defaultPortal?: boolean;
|
||||
}) {
|
||||
const isScreensharing = useCall((state) => state.screenShareEnabled);
|
||||
const screenRef = useCall((state) => state.screenRef);
|
||||
const [portalContainer, setPortalContainer] = useState<HTMLElement>();
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultPortal) return;
|
||||
setPortalContainer(screenRef?.current ?? undefined);
|
||||
}, [screenRef, defaultPortal]);
|
||||
|
||||
async function startWebShare() {
|
||||
try {
|
||||
await setScreenShareEnabled(true, {
|
||||
audio: true,
|
||||
systemAudio: "include",
|
||||
surfaceSwitching: "include",
|
||||
video: true,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("Failed to start web screen share", error);
|
||||
toast(
|
||||
"error",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to start screen sharing.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopShare() {
|
||||
try {
|
||||
await setScreenShareEnabled(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to stop screen share", error);
|
||||
toast("error", "Failed to stop screen sharing.");
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip>
|
||||
<Popover onOpenChange={setMenuOpen} open={menuOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
tooltip ? (
|
||||
<TooltipTrigger
|
||||
render={
|
||||
<Button
|
||||
variant={isScreensharing ? "subtleDefault" : "default"}
|
||||
className={className}
|
||||
>
|
||||
{isScreensharing ? (
|
||||
<MonitorDot
|
||||
style={{
|
||||
scale: (iconSize ? iconSize + 100 : 100) + "%",
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<ScreenShare
|
||||
style={{
|
||||
scale: (iconSize ? iconSize + 100 : 100) + "%",
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
variant={isScreensharing ? "subtleDefault" : "default"}
|
||||
className={className}
|
||||
>
|
||||
{isScreensharing ? (
|
||||
<MonitorDot
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
) : (
|
||||
<ScreenShare
|
||||
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
|
||||
/>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<PopoverContent
|
||||
className="flex w-40 flex-col gap-2"
|
||||
portalProps={{
|
||||
container: defaultPortal ? undefined : portalContainer,
|
||||
}}
|
||||
>
|
||||
<Button
|
||||
disabled={isScreensharing}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
|
||||
if (isTauri()) {
|
||||
setDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
|
||||
void startWebShare();
|
||||
}}
|
||||
>
|
||||
Start screenshare
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={!isScreensharing}
|
||||
onClick={() => {
|
||||
setMenuOpen(false);
|
||||
void stopShare();
|
||||
}}
|
||||
>
|
||||
Stop screenshare
|
||||
</Button>
|
||||
<Button variant="outline">Change quality</Button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{tooltip && (
|
||||
<TooltipContent
|
||||
portalProps={{
|
||||
container: defaultPortal ? undefined : portalContainer,
|
||||
}}
|
||||
>
|
||||
{tooltip}
|
||||
</TooltipContent>
|
||||
)}
|
||||
</Tooltip>
|
||||
|
||||
{isTauri() && (
|
||||
<ScreenShareDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
isScreensharing={isScreensharing}
|
||||
portalContainer={portalContainer}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,6 @@
|
|||
import { type ReactElement } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||
import { cloneElement } from "react";
|
||||
import type { MouseEvent as ReactMouseEvent, ReactElement, Ref } from "react";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
|
||||
|
||||
export type CallButtonProps = {
|
||||
className?: string;
|
||||
|
|
@ -12,20 +13,31 @@ export function iconScale(iconSize?: number) {
|
|||
return { scale: (iconSize ? iconSize + 100 : 100) + "%" };
|
||||
}
|
||||
|
||||
export function withTooltip(
|
||||
button: ReactElement,
|
||||
tooltip?: string,
|
||||
export function withButtonTooltip(
|
||||
trigger: ReactElement<{
|
||||
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||
ref?: Ref<HTMLElement>;
|
||||
}>,
|
||||
content?: string,
|
||||
portalContainer?: HTMLElement,
|
||||
) {
|
||||
if (!tooltip) {
|
||||
return button;
|
||||
}
|
||||
if (!content) return trigger;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={button} />
|
||||
<TooltipTrigger
|
||||
render={({ ref, onClick }) =>
|
||||
cloneElement(trigger, {
|
||||
ref,
|
||||
onClick: (event) => {
|
||||
onClick?.(event);
|
||||
trigger.props.onClick?.(event);
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TooltipContent portalProps={{ container: portalContainer }}>
|
||||
{tooltip}
|
||||
{content}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import {
|
|||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
import { PhoneIncoming, X } from "lucide-react";
|
||||
|
||||
|
|
@ -26,7 +26,10 @@ export default function InvitePopup({
|
|||
loading={null}
|
||||
component={(user) => (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent className="flex flex-col gap-5 items-center justify-center w-65 h-80">
|
||||
<DialogContent
|
||||
showCloseButton={false}
|
||||
className="flex flex-col gap-5 items-center justify-center w-65 h-80"
|
||||
>
|
||||
<Avatar className="size-30">
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback className="text-5xl">
|
||||
|
|
@ -36,14 +39,14 @@ export default function InvitePopup({
|
|||
<p className="text-xl font-medium">{user.Display}</p>
|
||||
<div className="w-full flex justify-center gap-3">
|
||||
<Button
|
||||
className="w-14 h-14"
|
||||
className="w-13 h-13!"
|
||||
variant="destructive"
|
||||
onClick={() => onAccept(false)}
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
className="w-14 h-14"
|
||||
className="w-13 h-13!"
|
||||
variant="subtleDefault"
|
||||
onClick={() => onAccept(true)}
|
||||
>
|
||||
|
|
|
|||
223
packages/call/src/components/mediaShareDialog.tsx
Normal file
223
packages/call/src/components/mediaShareDialog.tsx
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Switch,
|
||||
} from "@methanium/ui";
|
||||
import { toast } from "@tensamin/shared/log";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import {
|
||||
getMediaShareAdapter,
|
||||
type MediaShareKind,
|
||||
type MediaShareSource,
|
||||
} from "../mediaShare";
|
||||
import { startCameraShare, startScreenShare } from "../store";
|
||||
import MediaSourceCard from "./mediaSourceCard";
|
||||
|
||||
export default function MediaShareDialog({
|
||||
kind,
|
||||
open,
|
||||
onOpenChange,
|
||||
portalContainer,
|
||||
}: {
|
||||
kind: MediaShareKind;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
portalContainer?: HTMLElement;
|
||||
}) {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sources, setSources] = useState<MediaShareSource[]>([]);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
|
||||
const [shareAudio, setShareAudio] = useState(true);
|
||||
const [canShareAudio, setCanShareAudio] = useState(false);
|
||||
const [cameraPreview, setCameraPreview] = useState<MediaStream | null>(null);
|
||||
const [cameraPreviewVersion, setCameraPreviewVersion] = useState(0);
|
||||
const cameraPreviewRef = useRef<MediaStream | null>(null);
|
||||
|
||||
function stopCameraPreview() {
|
||||
cameraPreviewRef.current?.getTracks().forEach((track) => track.stop());
|
||||
cameraPreviewRef.current = null;
|
||||
setCameraPreview(null);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let active = true;
|
||||
setLoading(true);
|
||||
setSelectedSourceId(null);
|
||||
|
||||
Promise.all([
|
||||
getMediaShareAdapter().listSources(kind),
|
||||
getMediaShareAdapter().getCapabilities(),
|
||||
])
|
||||
.then(([nextSources, capabilities]) => {
|
||||
if (!active) return;
|
||||
const availableSources =
|
||||
kind === "camera" && nextSources.length === 0
|
||||
? [
|
||||
{
|
||||
id: "__default_camera__",
|
||||
kind: "camera" as const,
|
||||
name: "Default camera",
|
||||
},
|
||||
]
|
||||
: nextSources;
|
||||
setSources(availableSources);
|
||||
setSelectedSourceId(availableSources[0]?.id ?? null);
|
||||
setCanShareAudio(kind === "screen" && capabilities.canShareScreenAudio);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load media share sources", error);
|
||||
toast("error", "Failed to load media sources.");
|
||||
})
|
||||
.finally(() => active && setLoading(false));
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [kind, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (kind !== "camera" || !open || !selectedSourceId) {
|
||||
stopCameraPreview();
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
stopCameraPreview();
|
||||
const sourceId =
|
||||
selectedSourceId === "__default_camera__" ? undefined : selectedSourceId;
|
||||
|
||||
void navigator.mediaDevices
|
||||
.getUserMedia({
|
||||
audio: false,
|
||||
video: sourceId ? { deviceId: { exact: sourceId } } : true,
|
||||
})
|
||||
.then((stream) => {
|
||||
if (!active) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return;
|
||||
}
|
||||
cameraPreviewRef.current = stream;
|
||||
setCameraPreview(stream);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to preview camera", error);
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
stopCameraPreview();
|
||||
};
|
||||
}, [cameraPreviewVersion, kind, open, selectedSourceId]);
|
||||
|
||||
async function startSharing() {
|
||||
setLoading(true);
|
||||
try {
|
||||
if (kind === "camera") {
|
||||
stopCameraPreview();
|
||||
await startCameraShare(
|
||||
selectedSourceId === "__default_camera__"
|
||||
? undefined
|
||||
: (selectedSourceId ?? undefined),
|
||||
);
|
||||
} else {
|
||||
await startScreenShare({
|
||||
sourceId: selectedSourceId ?? undefined,
|
||||
includeAudio: canShareAudio && shareAudio,
|
||||
});
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error(`Failed to share ${kind}`, error);
|
||||
if (kind === "camera") {
|
||||
setCameraPreviewVersion((version) => version + 1);
|
||||
}
|
||||
toast(
|
||||
"error",
|
||||
error instanceof Error ? error.message : `Failed to share ${kind}.`,
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-[90%] gap-0 overflow-hidden p-0"
|
||||
portalProps={{ container: portalContainer }}
|
||||
>
|
||||
<DialogHeader className="p-4 pb-3">
|
||||
<DialogTitle>
|
||||
{kind === "camera" ? "Share a camera" : "Share your screen"}
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[75vh] flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{sources.map((source) => {
|
||||
const selected = source.id === selectedSourceId;
|
||||
return (
|
||||
<MediaSourceCard
|
||||
key={source.id}
|
||||
source={source}
|
||||
selected={selected}
|
||||
previewStream={selected ? cameraPreview : null}
|
||||
onSelect={() => setSelectedSourceId(source.id)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!loading && sources.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No {kind === "camera" ? "cameras" : "windows or displays"} found.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{canShareAudio ? (
|
||||
<div className="flex items-center justify-between gap-4 rounded-xl border p-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="share-system-audio">Share audio</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Some apps and protected media do not allow audio capture.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-system-audio"
|
||||
checked={shareAudio}
|
||||
onCheckedChange={setShareAudio}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading sources...
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="m-0! p-2!">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button
|
||||
disabled={loading || sources.length === 0 || !selectedSourceId}
|
||||
onClick={startSharing}
|
||||
>
|
||||
{loading ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Start sharing
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
77
packages/call/src/components/mediaSourceCard.tsx
Normal file
77
packages/call/src/components/mediaSourceCard.tsx
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
import { cn } from "@methanium/ui";
|
||||
import { AppWindow, Camera, MonitorUp } from "lucide-react";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { MediaShareSource } from "../mediaShare";
|
||||
|
||||
function SourceIcon({ source }: { source: MediaShareSource }) {
|
||||
const className = "size-8 text-muted-foreground";
|
||||
|
||||
if (source.kind === "camera") return <Camera className={className} />;
|
||||
if (source.kind === "window") return <AppWindow className={className} />;
|
||||
return <MonitorUp className={className} />;
|
||||
}
|
||||
|
||||
function VideoPreview({ stream }: { stream: MediaStream }) {
|
||||
const videoRef = useRef<HTMLVideoElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
const video = videoRef.current;
|
||||
if (!video) return;
|
||||
video.srcObject = stream;
|
||||
void video.play().catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
video.srcObject = null;
|
||||
};
|
||||
}, [stream]);
|
||||
|
||||
return (
|
||||
<video
|
||||
ref={videoRef}
|
||||
muted
|
||||
autoPlay
|
||||
playsInline
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export default function MediaSourceCard({
|
||||
source,
|
||||
selected,
|
||||
previewStream,
|
||||
onSelect,
|
||||
}: {
|
||||
source: MediaShareSource;
|
||||
selected: boolean;
|
||||
previewStream?: MediaStream | null;
|
||||
onSelect: () => void;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
aria-pressed={selected}
|
||||
onClick={onSelect}
|
||||
className={cn(
|
||||
"overflow-hidden rounded-xl border bg-card text-left transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
|
||||
selected
|
||||
? "border-4 border-(--primary)!"
|
||||
: "border-border hover:border-primary/50 hover:bg-muted/50",
|
||||
)}
|
||||
>
|
||||
<div className="flex aspect-video w-full items-center justify-center overflow-hidden bg-muted">
|
||||
{previewStream ? (
|
||||
<VideoPreview stream={previewStream} />
|
||||
) : source.thumbnail ? (
|
||||
<img
|
||||
src={source.thumbnail}
|
||||
alt=""
|
||||
className="h-full w-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<SourceIcon source={source} />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
|
@ -5,7 +5,8 @@ import {
|
|||
Button,
|
||||
ContextMenu as UIContextMenu,
|
||||
ContextMenuTrigger,
|
||||
} from "@tensamin/ui";
|
||||
cn,
|
||||
} from "@methanium/ui";
|
||||
import {
|
||||
focusParticipant,
|
||||
getRoomMetadata,
|
||||
|
|
@ -43,7 +44,7 @@ function TransparentButton({ children }: { children: React.ReactNode }) {
|
|||
);
|
||||
}
|
||||
|
||||
function getAverageImageColor(src: string) {
|
||||
export function getAverageImageColor(src: string) {
|
||||
return new Promise<string | undefined>((resolve) => {
|
||||
const image = new Image();
|
||||
|
||||
|
|
@ -177,6 +178,13 @@ export default function Base({
|
|||
Track.Source.ScreenShare,
|
||||
);
|
||||
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);
|
||||
useEffect(() => {
|
||||
|
|
@ -264,9 +272,14 @@ export default function Base({
|
|||
render={
|
||||
<div
|
||||
onClick={onClick}
|
||||
className={`relative w-full ${fill ? "h-full border-0" : "aspect-video border-2"} ${
|
||||
flush || fill ? "rounded-none" : "rounded-md"
|
||||
} ${flush && !fill ? "border-x-0" : ""}`}
|
||||
className={cn(
|
||||
"relative w-full",
|
||||
fill ? "h-full" : "aspect-video",
|
||||
flush && !fill && "border-x-0!",
|
||||
type === "user" &&
|
||||
isSpeaking &&
|
||||
"border-4 border-(--primary-foreground-alt)/75! rounded-lg",
|
||||
)}
|
||||
>
|
||||
<div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2">
|
||||
<>
|
||||
|
|
@ -303,8 +316,8 @@ export default function Base({
|
|||
<div
|
||||
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 ${
|
||||
flush ? "rounded-none" : "rounded-sm"
|
||||
} ${type === "user" && isSpeaking ? "border-4 border-(--primary-foreground-alt)/75" : "border-0"}`}
|
||||
flush ? "rounded-none" : "rounded-lg"
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: avatarBackgroundColor,
|
||||
containerType: "size",
|
||||
|
|
@ -335,23 +348,31 @@ export default function Base({
|
|||
</div>
|
||||
) : null)}
|
||||
|
||||
{type === "user" && (
|
||||
<Avatar
|
||||
style={{
|
||||
width: "32cqh",
|
||||
height: "32cqh",
|
||||
}}
|
||||
>
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback
|
||||
{type === "user" &&
|
||||
(cameraPublication?.track && !cameraDisabled ? (
|
||||
<VideoViewer
|
||||
fill={fill}
|
||||
flush={flush}
|
||||
participantId={participant.identity}
|
||||
publication={cameraPublication}
|
||||
/>
|
||||
) : (
|
||||
<Avatar
|
||||
style={{
|
||||
fontSize: "11cqh",
|
||||
width: "32cqh",
|
||||
height: "32cqh",
|
||||
}}
|
||||
>
|
||||
{user.Display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback
|
||||
style={{
|
||||
fontSize: "11cqh",
|
||||
}}
|
||||
>
|
||||
{user.Display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,9 +4,9 @@ import {
|
|||
ContextMenuItem,
|
||||
ContextMenuSeparator,
|
||||
Slider,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import type { User } from "@tensamin/user/context";
|
||||
import { useCall } from "../../store";
|
||||
import { setParticipantCameraDisabled, useCall } from "../../store";
|
||||
import { useState } from "react";
|
||||
|
||||
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
|
||||
|
|
@ -33,6 +33,9 @@ export default function ContextMenu({
|
|||
const watchedStreamParticipantIds = useCall(
|
||||
(state) => state.watchedStreamParticipantIds,
|
||||
);
|
||||
const cameraDisabled = useCall((state) =>
|
||||
state.disabledCameraParticipantIds.includes(user.UserId),
|
||||
);
|
||||
|
||||
return (
|
||||
<ContextMenuContent className="p-1">
|
||||
|
|
@ -71,6 +74,19 @@ export default function ContextMenu({
|
|||
<p>Mute Soundboard</p>
|
||||
<EmptyCheckboxIndicator checked={soundboardMuted} />
|
||||
</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
|
||||
checked={serverDeafened}
|
||||
onCheckedChange={setServerDeafened}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,22 @@
|
|||
import { Participant, Track } from "livekit-client";
|
||||
import VideoViewer from "./videoViewer";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
import { getRoom, stopWatchingStream, useCall } from "../store";
|
||||
import { getRoom, openCallPage, stopWatchingStream, useCall } from "../store";
|
||||
import { useState, useRef, useEffect, useCallback } from "react";
|
||||
import { Button, cn } from "@tensamin/ui";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Button,
|
||||
Card,
|
||||
cn,
|
||||
useIsMobile,
|
||||
useSidebar,
|
||||
} from "@methanium/ui";
|
||||
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(
|
||||
participant: Participant | undefined,
|
||||
|
|
@ -38,6 +50,237 @@ const MARGIN = 40;
|
|||
const MIN_SIZE = 240;
|
||||
const MAX_SIZE = 1000;
|
||||
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 }) {
|
||||
const screenSharePublication = getTrackPublicationBySource(
|
||||
|
|
@ -455,7 +698,10 @@ export function Popout({ participant }: { participant: Participant }) {
|
|||
export default function Wrapper() {
|
||||
const room = getRoom();
|
||||
const { pathname } = useLocation();
|
||||
const { openMobile } = useSidebar();
|
||||
const isMobile = useIsMobile();
|
||||
const state = useCall((state) => state.state);
|
||||
const callId = useCall((state) => state.callId);
|
||||
const watchedStreamParticipantIds = useCall(
|
||||
(state) => state.watchedStreamParticipantIds,
|
||||
);
|
||||
|
|
@ -466,6 +712,17 @@ export default function Wrapper() {
|
|||
String(lastFocusedParticipantId),
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
return (
|
||||
<MobileCallPill
|
||||
active={
|
||||
(!pathname.startsWith("/call") || openMobile) && state === "open"
|
||||
}
|
||||
callId={callId ?? ""}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!participant || !lastFocusedParticipantId) {
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,336 +0,0 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Button,
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
Label,
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
Switch,
|
||||
} from "@tensamin/ui";
|
||||
import {
|
||||
type DesktopScreenShareAudioOutput,
|
||||
type DesktopScreenShareCapabilities,
|
||||
type DesktopScreenShareSource,
|
||||
useDesktopMedia,
|
||||
} from "@tensamin/shared/desktopMedia";
|
||||
import { toast } from "@tensamin/shared/log";
|
||||
import { AppWindow, Loader2, MonitorUp } from "lucide-react";
|
||||
import type { ScreenShareCaptureOptions } from "livekit-client";
|
||||
import { setScreenShareEnabled, startLinuxDesktopScreenShare } from "../store";
|
||||
|
||||
const NONE_AUDIO_OUTPUT = "__none__";
|
||||
|
||||
function buildScreenShareOptions(
|
||||
source: DesktopScreenShareSource,
|
||||
capabilities: DesktopScreenShareCapabilities,
|
||||
selectedAudioOutputId: string,
|
||||
shareAudio: boolean,
|
||||
): ScreenShareCaptureOptions {
|
||||
const wantsAudio = capabilities.showAudioOutputSelector
|
||||
? selectedAudioOutputId !== NONE_AUDIO_OUTPUT
|
||||
: capabilities.hasReliableSystemAudio && shareAudio;
|
||||
|
||||
return {
|
||||
audio: wantsAudio
|
||||
? {
|
||||
autoGainControl: false,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
}
|
||||
: false,
|
||||
video: {
|
||||
displaySurface: source.kind === "window" ? "window" : "monitor",
|
||||
},
|
||||
systemAudio: wantsAudio ? "include" : "exclude",
|
||||
surfaceSwitching: "exclude",
|
||||
selfBrowserSurface: "exclude",
|
||||
contentHint: "detail",
|
||||
};
|
||||
}
|
||||
|
||||
export default function ScreenShareDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
isScreensharing,
|
||||
portalContainer,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
isScreensharing: boolean;
|
||||
portalContainer?: HTMLElement;
|
||||
}) {
|
||||
const {
|
||||
getScreenShareCapabilities,
|
||||
listScreenShareAudioOutputs,
|
||||
listScreenShareSources,
|
||||
} = useDesktopMedia();
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [sources, setSources] = useState<DesktopScreenShareSource[]>([]);
|
||||
const [audioOutputs, setAudioOutputs] = useState<
|
||||
DesktopScreenShareAudioOutput[]
|
||||
>([]);
|
||||
const [capabilities, setCapabilities] =
|
||||
useState<DesktopScreenShareCapabilities | null>(null);
|
||||
const [selectedSourceId, setSelectedSourceId] = useState<string | null>(null);
|
||||
const [selectedAudioOutputId, setSelectedAudioOutputId] =
|
||||
useState<string>(NONE_AUDIO_OUTPUT);
|
||||
const [shareAudio, setShareAudio] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
|
||||
let active = true;
|
||||
|
||||
setLoading(true);
|
||||
setSelectedSourceId(null);
|
||||
setSelectedAudioOutputId(NONE_AUDIO_OUTPUT);
|
||||
setShareAudio(false);
|
||||
|
||||
Promise.all([listScreenShareSources(), getScreenShareCapabilities()])
|
||||
.then(async ([nextSources, nextCapabilities]) => {
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSources(nextSources);
|
||||
setCapabilities(nextCapabilities);
|
||||
|
||||
if (nextCapabilities.showAudioOutputSelector) {
|
||||
const nextOutputs = await listScreenShareAudioOutputs();
|
||||
|
||||
if (!active) {
|
||||
return;
|
||||
}
|
||||
|
||||
setAudioOutputs(nextOutputs);
|
||||
} else {
|
||||
setAudioOutputs([]);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Failed to load desktop share sources", error);
|
||||
toast("error", "Failed to load screen share sources.");
|
||||
})
|
||||
.finally(() => {
|
||||
if (active) {
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [
|
||||
getScreenShareCapabilities,
|
||||
listScreenShareAudioOutputs,
|
||||
listScreenShareSources,
|
||||
open,
|
||||
]);
|
||||
|
||||
const selectedSource =
|
||||
sources.find((source) => source.id === selectedSourceId) ?? null;
|
||||
|
||||
async function startSharing() {
|
||||
if (!selectedSource || !capabilities) {
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
if (capabilities.runtime === "electron") {
|
||||
await startLinuxDesktopScreenShare(selectedSource.id);
|
||||
} else if (capabilities.platform === "linux") {
|
||||
await startLinuxDesktopScreenShare(selectedSource.id);
|
||||
} else {
|
||||
await setScreenShareEnabled(
|
||||
true,
|
||||
buildScreenShareOptions(
|
||||
selectedSource,
|
||||
capabilities,
|
||||
selectedAudioOutputId,
|
||||
shareAudio,
|
||||
),
|
||||
);
|
||||
}
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to start screen share", error);
|
||||
toast(
|
||||
"error",
|
||||
error instanceof Error
|
||||
? error.message
|
||||
: "Failed to start screen sharing.",
|
||||
);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function stopSharing() {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
await setScreenShareEnabled(false);
|
||||
onOpenChange(false);
|
||||
} catch (error) {
|
||||
console.error("Failed to stop screen share", error);
|
||||
toast("error", "Failed to stop screen sharing.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent
|
||||
className="max-w-3xl gap-0 overflow-hidden p-0"
|
||||
portalProps={{ container: portalContainer }}
|
||||
>
|
||||
<DialogHeader className="p-4 pb-3">
|
||||
<DialogTitle>Share your screen</DialogTitle>
|
||||
<DialogDescription>
|
||||
Choose a window or display you want to share.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex max-h-[75vh] flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
{sources.map((source) => {
|
||||
const selected = source.id === selectedSourceId;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
onClick={() => setSelectedSourceId(source.id)}
|
||||
className={[
|
||||
"flex min-h-28 flex-col justify-between rounded-xl border p-4 text-left transition-colors",
|
||||
selected
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-border bg-card hover:bg-muted/50",
|
||||
].join(" ")}
|
||||
>
|
||||
<div className="flex items-center gap-2 text-sm font-medium">
|
||||
{source.kind === "window" ? (
|
||||
<AppWindow className="size-4" />
|
||||
) : (
|
||||
<MonitorUp className="size-4" />
|
||||
)}
|
||||
{source.name}
|
||||
</div>
|
||||
{source.subtitle && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{source.subtitle}
|
||||
</p>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{!loading && sources.length === 0 && (
|
||||
<p className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
|
||||
No windows or displays found.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{capabilities?.showAudioOutputSelector ? (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label>Share audio output</Label>
|
||||
<Select
|
||||
value={selectedAudioOutputId}
|
||||
onValueChange={(value) =>
|
||||
setSelectedAudioOutputId(value ?? NONE_AUDIO_OUTPUT)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
<SelectValue>
|
||||
{selectedAudioOutputId === NONE_AUDIO_OUTPUT
|
||||
? "None"
|
||||
: (audioOutputs.find(
|
||||
(output) => output.id === selectedAudioOutputId,
|
||||
)?.name ?? "None")}
|
||||
</SelectValue>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value={NONE_AUDIO_OUTPUT}>None</SelectItem>
|
||||
{audioOutputs.map((output) => (
|
||||
<SelectItem key={output.id} value={output.id}>
|
||||
{output.isDefault
|
||||
? `${output.name} (Default)`
|
||||
: output.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
) : capabilities?.showAudioSwitch ? (
|
||||
<div className="flex flex-col gap-2 rounded-xl border p-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<Label htmlFor="share-system-audio">Share audio</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Share system audio alongside your screen when the runtime
|
||||
can provide it.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="share-system-audio"
|
||||
checked={shareAudio}
|
||||
disabled={!capabilities.hasReliableSystemAudio}
|
||||
onCheckedChange={setShareAudio}
|
||||
/>
|
||||
</div>
|
||||
{!capabilities.hasReliableSystemAudio && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
System audio sharing is not available on this platform.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
Loading sources...
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter className="m-0! p-2!">
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
Cancel
|
||||
</Button>
|
||||
{isScreensharing && (
|
||||
<Button
|
||||
variant="destructive"
|
||||
disabled={loading}
|
||||
onClick={stopSharing}
|
||||
>
|
||||
Stop sharing
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
disabled={loading || selectedSource == null}
|
||||
onClick={startSharing}
|
||||
>
|
||||
{loading ? <Loader2 className="size-4 animate-spin" /> : null}
|
||||
Start sharing
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
|
@ -9,8 +9,8 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
useIsMobile,
|
||||
} from "@tensamin/ui";
|
||||
import ScreenshareButton from "./buttons/screenshare";
|
||||
} from "@methanium/ui";
|
||||
import MediaShareButton from "./buttons/mediaShare";
|
||||
import MuteButton from "./buttons/mute";
|
||||
import DeafButton from "./buttons/deaf";
|
||||
import { Room, Track } from "livekit-client";
|
||||
|
|
@ -18,25 +18,36 @@ import { useEffect, useState } from "react";
|
|||
import { AreaChart, Area } from "recharts";
|
||||
import LeaveButton from "./buttons/leave";
|
||||
|
||||
export default function SidebarBox() {
|
||||
const state = useCall((store) => store.state);
|
||||
const isMobile = useIsMobile();
|
||||
async function getPing(room: Room): Promise<number | undefined> {
|
||||
const report = await room.localParticipant
|
||||
.getTrackPublication(Track.Source.Microphone)
|
||||
?.track?.getRTCStatsReport();
|
||||
|
||||
return state === "closed" ? null : (
|
||||
<Card className="p-1.5 gap-2" hidden={isMobile}>
|
||||
<CardHeader className="p-0! pb-2! border-b-2">
|
||||
<ConnectionBar />
|
||||
</CardHeader>
|
||||
<CardContent className="p-0! flex flex-col gap-1">
|
||||
<div className="flex justify-start gap-1">
|
||||
<MuteButton className="w-9 h-9" />
|
||||
<DeafButton className="w-9 h-9" />
|
||||
<ScreenshareButton className="w-9 h-9" defaultPortal />
|
||||
<LeaveButton className="w-9 h-9" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
if (!report) return;
|
||||
|
||||
let bestRtt: number | undefined;
|
||||
|
||||
report.forEach((stat) => {
|
||||
if (
|
||||
stat.type === "candidate-pair" &&
|
||||
stat.state === "succeeded" &&
|
||||
stat.currentRoundTripTime != null
|
||||
) {
|
||||
bestRtt = stat.currentRoundTripTime * 1000;
|
||||
}
|
||||
|
||||
if (stat.type === "remote-inbound-rtp" && stat.roundTripTime != null) {
|
||||
bestRtt = stat.roundTripTime * 1000;
|
||||
}
|
||||
});
|
||||
|
||||
if (bestRtt == null || bestRtt <= 0) return;
|
||||
|
||||
const roundedRtt = Math.round(bestRtt);
|
||||
|
||||
if (roundedRtt <= 0) return;
|
||||
|
||||
return roundedRtt;
|
||||
}
|
||||
|
||||
function ConnectionBar() {
|
||||
|
|
@ -47,14 +58,18 @@ function ConnectionBar() {
|
|||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger
|
||||
render={
|
||||
render={({ ref, onClick }) => (
|
||||
<Button
|
||||
onClick={() => openCallPage(callId || "")}
|
||||
ref={ref as React.Ref<HTMLButtonElement>}
|
||||
onClick={(event) => {
|
||||
onClick?.(event);
|
||||
openCallPage(callId || "");
|
||||
}}
|
||||
size="lg"
|
||||
variant={
|
||||
state === "open" && isEncrypted ? "subtleDefault" : "destructive"
|
||||
}
|
||||
className="flex justify-between items-center"
|
||||
className="flex items-center justify-between px-2!"
|
||||
>
|
||||
{state === "encrypting" && "Encrypting..."}
|
||||
{state === "connecting" && "Connecting..."}
|
||||
|
|
@ -70,7 +85,7 @@ function ConnectionBar() {
|
|||
<LockOpen color="var(--destructive)" />
|
||||
)}
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<TooltipContent>Click to open call page</TooltipContent>
|
||||
</Tooltip>
|
||||
|
|
@ -171,34 +186,23 @@ export function TinyPingGraph() {
|
|||
);
|
||||
}
|
||||
|
||||
async function getPing(room: Room): Promise<number | undefined> {
|
||||
const report = await room.localParticipant
|
||||
.getTrackPublication(Track.Source.Microphone)
|
||||
?.track?.getRTCStatsReport();
|
||||
export default function SidebarBox() {
|
||||
const state = useCall((store) => store.state);
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
if (!report) return;
|
||||
|
||||
let bestRtt: number | undefined;
|
||||
|
||||
report.forEach((stat) => {
|
||||
if (
|
||||
stat.type === "candidate-pair" &&
|
||||
stat.state === "succeeded" &&
|
||||
stat.currentRoundTripTime != null
|
||||
) {
|
||||
bestRtt = stat.currentRoundTripTime * 1000;
|
||||
}
|
||||
|
||||
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;
|
||||
return state === "closed" ? null : (
|
||||
<Card className="p-1.5 gap-2" hidden={isMobile}>
|
||||
<CardHeader className="p-0! pb-2! border-b-2">
|
||||
<ConnectionBar />
|
||||
</CardHeader>
|
||||
<CardContent className="p-0! flex flex-col gap-1">
|
||||
<div className="flex justify-center gap-1">
|
||||
<MuteButton className="h-9! w-[24%]" />
|
||||
<DeafButton className="h-9! w-[24%]" />
|
||||
<MediaShareButton className="h-9! w-[24%]!" defaultPortal />
|
||||
<LeaveButton className="h-9! w-[24%]" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@ import {
|
|||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
|
||||
export default function TopBar() {
|
||||
const { get } = useUser();
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { VideoTrack, useParticipantTracks } from "@livekit/components-react";
|
||||
import { TrackPublication } from "livekit-client";
|
||||
import { getRoom } from "../store";
|
||||
import { cn } from "@tensamin/ui";
|
||||
import { cn } from "@methanium/ui";
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
export default function VideoViewer({
|
||||
|
|
|
|||
84
packages/call/src/mediaShare/browser.ts
Normal file
84
packages/call/src/mediaShare/browser.ts
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import type {
|
||||
MediaShareAdapter,
|
||||
MediaShareCapabilities,
|
||||
MediaShareKind,
|
||||
MediaShareRequest,
|
||||
MediaShareSession,
|
||||
MediaShareSource,
|
||||
} from "./types";
|
||||
|
||||
export async function listCameraSources(): Promise<MediaShareSource[]> {
|
||||
const permissionStream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: true,
|
||||
});
|
||||
let devices: MediaDeviceInfo[];
|
||||
try {
|
||||
devices = await navigator.mediaDevices.enumerateDevices();
|
||||
} finally {
|
||||
permissionStream.getTracks().forEach((track) => track.stop());
|
||||
}
|
||||
let cameraIndex = 0;
|
||||
|
||||
return devices
|
||||
.filter((device) => device.kind === "videoinput")
|
||||
.map((device) => ({
|
||||
id: device.deviceId,
|
||||
kind: "camera" as const,
|
||||
name: device.label || `Camera ${++cameraIndex}`,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function startCamera(
|
||||
sourceId?: string,
|
||||
): Promise<MediaShareSession> {
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: false,
|
||||
video: sourceId ? { deviceId: { exact: sourceId } } : true,
|
||||
});
|
||||
|
||||
return streamSession(stream);
|
||||
}
|
||||
|
||||
function streamSession(stream: MediaStream): MediaShareSession {
|
||||
return {
|
||||
tracks: stream.getTracks(),
|
||||
stop: async () => {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export class BrowserMediaShareAdapter implements MediaShareAdapter {
|
||||
async getCapabilities(): Promise<MediaShareCapabilities> {
|
||||
return {
|
||||
runtime: "browser",
|
||||
screenPicker: "native",
|
||||
canShareScreenAudio: true,
|
||||
canSelectScreenAudioOutput: false,
|
||||
};
|
||||
}
|
||||
|
||||
async listSources(kind: MediaShareKind): Promise<MediaShareSource[]> {
|
||||
return kind === "camera" ? listCameraSources() : [];
|
||||
}
|
||||
|
||||
async start(request: MediaShareRequest): Promise<MediaShareSession> {
|
||||
if (request.kind === "camera") {
|
||||
return startCamera(request.sourceId);
|
||||
}
|
||||
|
||||
const options: DisplayMediaStreamOptions & {
|
||||
systemAudio: "include" | "exclude";
|
||||
surfaceSwitching: "include" | "exclude";
|
||||
} = {
|
||||
audio: request.includeAudio ?? true,
|
||||
video: true,
|
||||
systemAudio: request.includeAudio === false ? "exclude" : "include",
|
||||
surfaceSwitching: "include",
|
||||
};
|
||||
const stream = await navigator.mediaDevices.getDisplayMedia(options);
|
||||
|
||||
return streamSession(stream);
|
||||
}
|
||||
}
|
||||
154
packages/call/src/mediaShare/controller.ts
Normal file
154
packages/call/src/mediaShare/controller.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { log } from "@tensamin/shared/log";
|
||||
import { type LocalTrack, Room, Track } from "livekit-client";
|
||||
import {
|
||||
getMediaShareAdapter,
|
||||
type MediaShareKind,
|
||||
type MediaShareRequest,
|
||||
type MediaShareSession,
|
||||
} from ".";
|
||||
|
||||
export type LocalMediaShareSession = {
|
||||
tracks: Array<LocalTrack | MediaStreamTrack>;
|
||||
capture: MediaShareSession;
|
||||
};
|
||||
|
||||
type MediaShareStoreState = {
|
||||
screenShareSession: LocalMediaShareSession | null;
|
||||
cameraSession: LocalMediaShareSession | null;
|
||||
};
|
||||
|
||||
type MediaShareStoreSetState = (
|
||||
updater:
|
||||
| Partial<MediaShareStoreState>
|
||||
| ((state: MediaShareStoreState) => Partial<MediaShareStoreState>),
|
||||
) => void;
|
||||
|
||||
type MediaShareControllerOptions = {
|
||||
room: Room;
|
||||
getState: () => MediaShareStoreState;
|
||||
setState: MediaShareStoreSetState;
|
||||
getLocalParticipantId: () => number | null;
|
||||
startWatching: (participantId: number) => void;
|
||||
stopWatching: (participantId: number) => void;
|
||||
syncParticipantState: () => void;
|
||||
};
|
||||
|
||||
export function createMediaShareController({
|
||||
room,
|
||||
getState,
|
||||
setState,
|
||||
getLocalParticipantId,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
syncParticipantState,
|
||||
}: MediaShareControllerOptions) {
|
||||
function getSession(kind: MediaShareKind) {
|
||||
return kind === "screen"
|
||||
? getState().screenShareSession
|
||||
: getState().cameraSession;
|
||||
}
|
||||
|
||||
function setSession(
|
||||
kind: MediaShareKind,
|
||||
session: LocalMediaShareSession | null,
|
||||
) {
|
||||
setState(
|
||||
kind === "screen"
|
||||
? { screenShareSession: session }
|
||||
: { cameraSession: session },
|
||||
);
|
||||
}
|
||||
|
||||
async function clearPublishedShare(kind: MediaShareKind) {
|
||||
const session = getSession(kind);
|
||||
if (!session) return;
|
||||
|
||||
setSession(kind, null);
|
||||
await Promise.all(
|
||||
session.tracks.map((track) =>
|
||||
room.localParticipant.unpublishTrack(track, true).catch((error) => {
|
||||
log(1, "call", "red", `Failed to unpublish ${kind} track`, error);
|
||||
}),
|
||||
),
|
||||
);
|
||||
await session.capture.stop().catch((error) => {
|
||||
log(1, "call", "red", `Failed to stop ${kind} capture`, error);
|
||||
});
|
||||
|
||||
if (kind === "screen") {
|
||||
const localParticipantId = getLocalParticipantId();
|
||||
if (localParticipantId != null) stopWatching(localParticipantId);
|
||||
}
|
||||
}
|
||||
|
||||
async function publishShare(
|
||||
kind: MediaShareKind,
|
||||
capture: MediaShareSession,
|
||||
) {
|
||||
if (capture.tracks.length === 0) {
|
||||
await capture.stop();
|
||||
throw new Error(`No ${kind} tracks were created.`);
|
||||
}
|
||||
|
||||
const published: MediaStreamTrack[] = [];
|
||||
try {
|
||||
for (const track of capture.tracks) {
|
||||
await room.localParticipant.publishTrack(track, {
|
||||
source:
|
||||
track.kind === Track.Kind.Audio
|
||||
? Track.Source.ScreenShareAudio
|
||||
: kind === "screen"
|
||||
? Track.Source.ScreenShare
|
||||
: Track.Source.Camera,
|
||||
});
|
||||
published.push(track);
|
||||
}
|
||||
} catch (error) {
|
||||
await Promise.all(
|
||||
published.map((track) =>
|
||||
room.localParticipant.unpublishTrack(track, true),
|
||||
),
|
||||
);
|
||||
await capture.stop();
|
||||
throw error;
|
||||
}
|
||||
|
||||
for (const track of capture.tracks) {
|
||||
track.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
void stop(kind);
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
setSession(kind, { tracks: capture.tracks, capture });
|
||||
syncParticipantState();
|
||||
|
||||
if (kind === "screen") {
|
||||
const localParticipantId = getLocalParticipantId();
|
||||
if (localParticipantId != null) startWatching(localParticipantId);
|
||||
}
|
||||
}
|
||||
|
||||
async function start(request: MediaShareRequest) {
|
||||
await clearPublishedShare(request.kind);
|
||||
const capture = await getMediaShareAdapter().start(request);
|
||||
await publishShare(request.kind, capture);
|
||||
}
|
||||
|
||||
async function stop(kind: MediaShareKind) {
|
||||
await clearPublishedShare(kind);
|
||||
syncParticipantState();
|
||||
}
|
||||
|
||||
async function clearAll() {
|
||||
await Promise.all([
|
||||
clearPublishedShare("screen"),
|
||||
clearPublishedShare("camera"),
|
||||
]);
|
||||
}
|
||||
|
||||
return { clearAll, start, stop };
|
||||
}
|
||||
54
packages/call/src/mediaShare/electron.ts
Normal file
54
packages/call/src/mediaShare/electron.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import type {} from "@tensamin/shared/desktopMedia";
|
||||
import { BrowserMediaShareAdapter, listCameraSources } from "./browser";
|
||||
import type {
|
||||
MediaShareCapabilities,
|
||||
MediaShareKind,
|
||||
MediaShareRequest,
|
||||
MediaShareSession,
|
||||
MediaShareSource,
|
||||
} from "./types";
|
||||
|
||||
export class ElectronMediaShareAdapter extends BrowserMediaShareAdapter {
|
||||
override async getCapabilities(): Promise<MediaShareCapabilities> {
|
||||
const capabilities =
|
||||
await window.tensaminDesktop?.media?.getScreenShareCapabilities?.();
|
||||
|
||||
return {
|
||||
runtime: "electron",
|
||||
screenPicker: "sources",
|
||||
canShareScreenAudio: capabilities?.hasReliableSystemAudio ?? false,
|
||||
canSelectScreenAudioOutput:
|
||||
capabilities?.showAudioOutputSelector ?? false,
|
||||
};
|
||||
}
|
||||
|
||||
override async listSources(
|
||||
kind: MediaShareKind,
|
||||
): Promise<MediaShareSource[]> {
|
||||
if (kind === "camera") {
|
||||
return listCameraSources();
|
||||
}
|
||||
|
||||
return (
|
||||
(await window.tensaminDesktop?.media?.listScreenShareSources?.()) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
override async start(request: MediaShareRequest): Promise<MediaShareSession> {
|
||||
if (request.kind === "camera") {
|
||||
return super.start(request);
|
||||
}
|
||||
|
||||
if (!request.sourceId) {
|
||||
throw new Error("Choose a screen or window to share.");
|
||||
}
|
||||
|
||||
const select = window.tensaminDesktop?.media?.selectScreenShareSource;
|
||||
if (!select) {
|
||||
throw new Error("Electron screen capture is unavailable.");
|
||||
}
|
||||
|
||||
await select(request.sourceId);
|
||||
return super.start({ ...request, sourceId: undefined });
|
||||
}
|
||||
}
|
||||
27
packages/call/src/mediaShare/index.ts
Normal file
27
packages/call/src/mediaShare/index.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { BrowserMediaShareAdapter } from "./browser";
|
||||
import { ElectronMediaShareAdapter } from "./electron";
|
||||
import { TauriMediaShareAdapter } from "./tauri";
|
||||
import type { MediaShareAdapter } from "./types";
|
||||
|
||||
let adapter: MediaShareAdapter | null = null;
|
||||
|
||||
export function getMediaShareAdapter(): MediaShareAdapter {
|
||||
if (!adapter) {
|
||||
adapter = window.tensaminMobileMedia
|
||||
? new TauriMediaShareAdapter()
|
||||
: window.tensaminDesktop?.media
|
||||
? new ElectronMediaShareAdapter()
|
||||
: new BrowserMediaShareAdapter();
|
||||
}
|
||||
|
||||
return adapter;
|
||||
}
|
||||
|
||||
export type {
|
||||
MediaShareAdapter,
|
||||
MediaShareCapabilities,
|
||||
MediaShareKind,
|
||||
MediaShareRequest,
|
||||
MediaShareSession,
|
||||
MediaShareSource,
|
||||
} from "./types";
|
||||
270
packages/call/src/mediaShare/tauri.ts
Normal file
270
packages/call/src/mediaShare/tauri.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
import { listCameraSources, startCamera } from "./browser";
|
||||
import type {
|
||||
MediaShareAdapter,
|
||||
MediaShareCapabilities,
|
||||
MediaShareKind,
|
||||
MediaShareRequest,
|
||||
MediaShareSession,
|
||||
MediaShareSource,
|
||||
} from "./types";
|
||||
|
||||
type MobileMediaApi = {
|
||||
startScreenShare: (includeAudio: boolean) => void;
|
||||
stopScreenShare: () => void;
|
||||
requestCameraPermission: () => void;
|
||||
};
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
tensaminMobileMedia?: MobileMediaApi;
|
||||
}
|
||||
}
|
||||
|
||||
type FrameDetail = {
|
||||
data: string;
|
||||
mimeType: string;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type AudioDetail = {
|
||||
data: string;
|
||||
sampleRate: number;
|
||||
channelCount: number;
|
||||
encoding: "pcm16le";
|
||||
};
|
||||
|
||||
function eventDetail<T>(event: Event): T {
|
||||
return (event as CustomEvent<T>).detail;
|
||||
}
|
||||
|
||||
function decodeBase64(value: string): Uint8Array {
|
||||
const decoded = atob(value);
|
||||
const bytes = new Uint8Array(decoded.length);
|
||||
for (let index = 0; index < decoded.length; index += 1) {
|
||||
bytes[index] = decoded.charCodeAt(index);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
async function requestCameraPermission() {
|
||||
const bridge = window.tensaminMobileMedia;
|
||||
if (!bridge) throw new Error("Tauri mobile media bridge is unavailable.");
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
const timeout = window.setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error("Timed out waiting for camera permission."));
|
||||
}, 30_000);
|
||||
const onPermission = (event: Event) => {
|
||||
cleanup();
|
||||
const permission = eventDetail<{ camera: boolean }>(event);
|
||||
if (permission.camera) {
|
||||
resolve();
|
||||
} else {
|
||||
reject(new Error("Camera permission was denied."));
|
||||
}
|
||||
};
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timeout);
|
||||
window.removeEventListener(
|
||||
"tensamin-mobile-camera-permission",
|
||||
onPermission,
|
||||
);
|
||||
};
|
||||
|
||||
window.addEventListener("tensamin-mobile-camera-permission", onPermission);
|
||||
bridge.requestCameraPermission();
|
||||
});
|
||||
}
|
||||
|
||||
async function startMobileScreen(
|
||||
includeAudio: boolean,
|
||||
): Promise<MediaShareSession> {
|
||||
const bridge = window.tensaminMobileMedia;
|
||||
if (!bridge) throw new Error("Tauri mobile media bridge is unavailable.");
|
||||
|
||||
return new Promise<MediaShareSession>((resolve, reject) => {
|
||||
const canvas = document.createElement("canvas");
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) {
|
||||
reject(new Error("Unable to create the mobile capture canvas."));
|
||||
return;
|
||||
}
|
||||
|
||||
const stream = canvas.captureStream(15);
|
||||
let audioContext: AudioContext | null = null;
|
||||
let audioNode: ScriptProcessorNode | null = null;
|
||||
let audioDestination: MediaStreamAudioDestinationNode | null = null;
|
||||
const audioQueue: Float32Array[] = [];
|
||||
let audioQueueOffset = 0;
|
||||
let queuedAudioSamples = 0;
|
||||
let started = false;
|
||||
let resolved = false;
|
||||
let firstFrame = false;
|
||||
let lastError: Error | null = null;
|
||||
let errorTimer = 0;
|
||||
|
||||
const timeout = window.setTimeout(() => {
|
||||
cleanup();
|
||||
bridge.stopScreenShare();
|
||||
reject(
|
||||
lastError ?? new Error("Timed out starting mobile screen sharing."),
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
const complete = () => {
|
||||
if (!started || !firstFrame || resolved) return;
|
||||
resolved = true;
|
||||
window.clearTimeout(timeout);
|
||||
resolve({
|
||||
tracks: stream.getTracks(),
|
||||
stop: async () => {
|
||||
bridge.stopScreenShare();
|
||||
cleanup();
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const onStarted = (event: Event) => {
|
||||
started = true;
|
||||
const detail = eventDetail<{ includeAudio: boolean }>(event);
|
||||
if (detail.includeAudio) {
|
||||
audioContext = new AudioContext({ sampleRate: 48_000 });
|
||||
audioNode = audioContext.createScriptProcessor(2048, 0, 1);
|
||||
audioDestination = audioContext.createMediaStreamDestination();
|
||||
audioNode.onaudioprocess = ({ outputBuffer }) => {
|
||||
const output = outputBuffer.getChannelData(0);
|
||||
output.fill(0);
|
||||
let outputOffset = 0;
|
||||
while (outputOffset < output.length && audioQueue.length > 0) {
|
||||
const chunk = audioQueue[0];
|
||||
const available = chunk.length - audioQueueOffset;
|
||||
const count = Math.min(available, output.length - outputOffset);
|
||||
output.set(
|
||||
chunk.subarray(audioQueueOffset, audioQueueOffset + count),
|
||||
outputOffset,
|
||||
);
|
||||
outputOffset += count;
|
||||
audioQueueOffset += count;
|
||||
queuedAudioSamples -= count;
|
||||
if (audioQueueOffset === chunk.length) {
|
||||
audioQueue.shift();
|
||||
audioQueueOffset = 0;
|
||||
}
|
||||
}
|
||||
};
|
||||
audioNode.connect(audioDestination);
|
||||
void audioContext.resume();
|
||||
for (const track of audioDestination.stream.getAudioTracks()) {
|
||||
stream.addTrack(track);
|
||||
}
|
||||
}
|
||||
complete();
|
||||
};
|
||||
|
||||
const onFrame = (event: Event) => {
|
||||
const detail = eventDetail<FrameDetail>(event);
|
||||
const image = new Image();
|
||||
image.onload = () => {
|
||||
if (canvas.width !== detail.width || canvas.height !== detail.height) {
|
||||
canvas.width = detail.width;
|
||||
canvas.height = detail.height;
|
||||
}
|
||||
context.drawImage(image, 0, 0, canvas.width, canvas.height);
|
||||
firstFrame = true;
|
||||
complete();
|
||||
};
|
||||
image.src = `data:${detail.mimeType};base64,${detail.data}`;
|
||||
};
|
||||
|
||||
const onAudio = (event: Event) => {
|
||||
const bytes = decodeBase64(eventDetail<AudioDetail>(event).data);
|
||||
const samples = new Int16Array(
|
||||
bytes.buffer,
|
||||
bytes.byteOffset,
|
||||
Math.floor(bytes.byteLength / 2),
|
||||
);
|
||||
const chunk = new Float32Array(samples.length);
|
||||
for (let index = 0; index < samples.length; index += 1) {
|
||||
chunk[index] = samples[index] / 32768;
|
||||
}
|
||||
audioQueue.push(chunk);
|
||||
queuedAudioSamples += chunk.length;
|
||||
const maximumQueuedSamples = 48_000 * 2;
|
||||
while (queuedAudioSamples > maximumQueuedSamples && audioQueue.length) {
|
||||
const dropped = audioQueue.shift();
|
||||
if (!dropped) break;
|
||||
queuedAudioSamples -= dropped.length - audioQueueOffset;
|
||||
audioQueueOffset = 0;
|
||||
}
|
||||
};
|
||||
|
||||
const onStopped = () => {
|
||||
cleanup();
|
||||
if (!resolved) reject(new Error("Mobile screen sharing was stopped."));
|
||||
};
|
||||
|
||||
const onError = (event: Event) => {
|
||||
lastError = new Error(eventDetail<{ message: string }>(event).message);
|
||||
window.clearTimeout(errorTimer);
|
||||
errorTimer = window.setTimeout(() => {
|
||||
if (!started && !resolved) {
|
||||
cleanup();
|
||||
reject(lastError ?? new Error("Mobile screen sharing failed."));
|
||||
}
|
||||
}, 300);
|
||||
};
|
||||
|
||||
const listeners: Array<[string, EventListener]> = [
|
||||
["tensamin-mobile-screen-started", onStarted],
|
||||
["tensamin-mobile-screen-frame", onFrame],
|
||||
["tensamin-mobile-screen-audio", onAudio],
|
||||
["tensamin-mobile-screen-stopped", onStopped],
|
||||
["tensamin-mobile-screen-error", onError],
|
||||
];
|
||||
const cleanup = () => {
|
||||
window.clearTimeout(timeout);
|
||||
window.clearTimeout(errorTimer);
|
||||
listeners.forEach(([name, listener]) =>
|
||||
window.removeEventListener(name, listener),
|
||||
);
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
audioNode?.disconnect();
|
||||
audioNode = null;
|
||||
void audioContext?.close();
|
||||
audioContext = null;
|
||||
};
|
||||
|
||||
listeners.forEach(([name, listener]) =>
|
||||
window.addEventListener(name, listener),
|
||||
);
|
||||
bridge.startScreenShare(includeAudio);
|
||||
});
|
||||
}
|
||||
|
||||
export class TauriMediaShareAdapter implements MediaShareAdapter {
|
||||
async getCapabilities(): Promise<MediaShareCapabilities> {
|
||||
return {
|
||||
runtime: "tauri",
|
||||
screenPicker: "system",
|
||||
canShareScreenAudio: true,
|
||||
canSelectScreenAudioOutput: false,
|
||||
};
|
||||
}
|
||||
|
||||
async listSources(kind: MediaShareKind): Promise<MediaShareSource[]> {
|
||||
if (kind !== "camera") return [];
|
||||
await requestCameraPermission();
|
||||
return listCameraSources();
|
||||
}
|
||||
|
||||
async start(request: MediaShareRequest): Promise<MediaShareSession> {
|
||||
if (request.kind === "screen") {
|
||||
return startMobileScreen(request.includeAudio ?? true);
|
||||
}
|
||||
|
||||
await requestCameraPermission();
|
||||
return startCamera(request.sourceId);
|
||||
}
|
||||
}
|
||||
33
packages/call/src/mediaShare/types.ts
Normal file
33
packages/call/src/mediaShare/types.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
export type MediaShareKind = "screen" | "camera";
|
||||
|
||||
export type MediaShareSource = {
|
||||
id: string;
|
||||
kind: "screen" | "window" | "camera";
|
||||
name: string;
|
||||
subtitle?: string | null;
|
||||
thumbnail?: string | null;
|
||||
};
|
||||
|
||||
export type MediaShareCapabilities = {
|
||||
runtime: "browser" | "electron" | "tauri";
|
||||
screenPicker: "native" | "sources" | "system";
|
||||
canShareScreenAudio: boolean;
|
||||
canSelectScreenAudioOutput: boolean;
|
||||
};
|
||||
|
||||
export type MediaShareRequest = {
|
||||
kind: MediaShareKind;
|
||||
sourceId?: string;
|
||||
includeAudio?: boolean;
|
||||
};
|
||||
|
||||
export type MediaShareSession = {
|
||||
tracks: MediaStreamTrack[];
|
||||
stop: () => Promise<void>;
|
||||
};
|
||||
|
||||
export interface MediaShareAdapter {
|
||||
getCapabilities(): Promise<MediaShareCapabilities>;
|
||||
listSources(kind: MediaShareKind): Promise<MediaShareSource[]>;
|
||||
start(request: MediaShareRequest): Promise<MediaShareSession>;
|
||||
}
|
||||
|
|
@ -31,8 +31,13 @@ function copyDocumentStyles(targetDocument: Document) {
|
|||
}
|
||||
}
|
||||
|
||||
function syncDocumentClasses(targetDocument: Document) {
|
||||
targetDocument.documentElement.className = document.documentElement.className;
|
||||
function syncDocumentAttributes(targetDocument: Document) {
|
||||
for (const attribute of document.documentElement.attributes) {
|
||||
targetDocument.documentElement.setAttribute(
|
||||
attribute.name,
|
||||
attribute.value,
|
||||
);
|
||||
}
|
||||
targetDocument.body.className = document.body.className;
|
||||
}
|
||||
|
||||
|
|
@ -58,12 +63,12 @@ function PopoutScreen() {
|
|||
popoutWindow.document.title = document.title;
|
||||
popoutWindow.document.body.innerHTML = "";
|
||||
popoutWindow.document.body.style.margin = "0";
|
||||
copyDocumentStyles(popoutWindow.document);
|
||||
syncDocumentAttributes(popoutWindow.document);
|
||||
|
||||
popoutWindow.document.documentElement.style.height = "100%";
|
||||
popoutWindow.document.body.style.height = "100%";
|
||||
|
||||
copyDocumentStyles(popoutWindow.document);
|
||||
syncDocumentClasses(popoutWindow.document);
|
||||
|
||||
const containerElement = popoutWindow.document.createElement("div");
|
||||
containerElement.style.width = "100%";
|
||||
containerElement.style.height = "100%";
|
||||
|
|
|
|||
|
|
@ -1,177 +0,0 @@
|
|||
import { log } from "@tensamin/shared/log";
|
||||
import type {} from "@tensamin/shared/desktopMedia";
|
||||
import {
|
||||
type LocalTrack,
|
||||
Room,
|
||||
type ScreenShareCaptureOptions,
|
||||
Track,
|
||||
} from "livekit-client";
|
||||
|
||||
export type ScreenShareSession = {
|
||||
tracks: Array<LocalTrack | MediaStreamTrack>;
|
||||
cleanup?: () => void;
|
||||
};
|
||||
|
||||
type ScreenShareStoreState = {
|
||||
screenShareSession: ScreenShareSession | null;
|
||||
};
|
||||
|
||||
type ScreenShareStoreSetState = (
|
||||
updater:
|
||||
| Partial<ScreenShareStoreState>
|
||||
| ((state: ScreenShareStoreState) => Partial<ScreenShareStoreState>),
|
||||
) => void;
|
||||
|
||||
type ScreenShareControllerOptions = {
|
||||
room: Room;
|
||||
getState: () => ScreenShareStoreState;
|
||||
setState: ScreenShareStoreSetState;
|
||||
getLocalParticipantId: () => number | null;
|
||||
startWatching: (participantId: number, options?: { focus?: boolean }) => void;
|
||||
stopWatching: (participantId: number) => void;
|
||||
syncParticipantState: () => void;
|
||||
};
|
||||
|
||||
export function createScreenShareController({
|
||||
room,
|
||||
getState,
|
||||
setState,
|
||||
getLocalParticipantId,
|
||||
startWatching,
|
||||
stopWatching,
|
||||
syncParticipantState,
|
||||
}: ScreenShareControllerOptions) {
|
||||
async function clearPublishedScreenShare() {
|
||||
const screenShareSession = getState().screenShareSession;
|
||||
|
||||
if (!screenShareSession) {
|
||||
return;
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
screenShareSession.tracks.map((track) =>
|
||||
room.localParticipant.unpublishTrack(track, true).catch((error) => {
|
||||
log(
|
||||
1,
|
||||
"call",
|
||||
"red",
|
||||
"Failed to unpublish screen share track",
|
||||
error,
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
screenShareSession.cleanup?.();
|
||||
|
||||
const localParticipantId = getLocalParticipantId();
|
||||
|
||||
if (localParticipantId != null) {
|
||||
stopWatching(localParticipantId);
|
||||
}
|
||||
|
||||
setState({ screenShareSession: null });
|
||||
}
|
||||
|
||||
async function publishScreenShareTracks(
|
||||
tracks: Array<LocalTrack | MediaStreamTrack>,
|
||||
cleanup?: () => void,
|
||||
) {
|
||||
if (tracks.length === 0) {
|
||||
throw new Error("No screen share tracks were created.");
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
tracks.map((track) =>
|
||||
room.localParticipant.publishTrack(track, {
|
||||
source:
|
||||
track.kind === Track.Kind.Video
|
||||
? Track.Source.ScreenShare
|
||||
: Track.Source.ScreenShareAudio,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
for (const track of tracks) {
|
||||
const mediaStreamTrack =
|
||||
track instanceof MediaStreamTrack ? track : track.mediaStreamTrack;
|
||||
|
||||
mediaStreamTrack.addEventListener(
|
||||
"ended",
|
||||
() => {
|
||||
void stopScreenShare();
|
||||
},
|
||||
{ once: true },
|
||||
);
|
||||
}
|
||||
|
||||
setState({ screenShareSession: { tracks, cleanup } });
|
||||
syncParticipantState();
|
||||
|
||||
const localParticipantId = getLocalParticipantId();
|
||||
|
||||
if (localParticipantId != null) {
|
||||
startWatching(localParticipantId, { focus: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function startScreenShare(options?: ScreenShareCaptureOptions) {
|
||||
await clearPublishedScreenShare();
|
||||
|
||||
const tracks = await room.localParticipant.createScreenTracks(options);
|
||||
|
||||
await publishScreenShareTracks(tracks, () => {
|
||||
tracks.forEach((track) => track.stop());
|
||||
});
|
||||
}
|
||||
|
||||
async function startLinuxDesktopScreenShare(sourceId: string) {
|
||||
await clearPublishedScreenShare();
|
||||
|
||||
if (window.tensaminDesktop?.media?.selectScreenShareSource) {
|
||||
await window.tensaminDesktop.media.selectScreenShareSource(sourceId);
|
||||
const tracks = await room.localParticipant.createScreenTracks({
|
||||
audio: false,
|
||||
video: true,
|
||||
systemAudio: "exclude",
|
||||
surfaceSwitching: "exclude",
|
||||
selfBrowserSurface: "exclude",
|
||||
contentHint: "detail",
|
||||
});
|
||||
|
||||
await publishScreenShareTracks(tracks, () => {
|
||||
tracks.forEach((track) => track.stop());
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`Electron desktop media bridge is unavailable. Cannot capture ${sourceId}.`,
|
||||
);
|
||||
}
|
||||
|
||||
async function stopScreenShare() {
|
||||
await clearPublishedScreenShare();
|
||||
syncParticipantState();
|
||||
}
|
||||
|
||||
async function setScreenShareEnabled(
|
||||
enabled: boolean,
|
||||
options?: ScreenShareCaptureOptions,
|
||||
) {
|
||||
if (enabled) {
|
||||
await startScreenShare(options);
|
||||
return;
|
||||
}
|
||||
|
||||
await stopScreenShare();
|
||||
}
|
||||
|
||||
return {
|
||||
clearPublishedScreenShare,
|
||||
startLinuxDesktopScreenShare,
|
||||
startScreenShare,
|
||||
stopScreenShare,
|
||||
setScreenShareEnabled,
|
||||
};
|
||||
}
|
||||
|
|
@ -2,11 +2,13 @@ import { create } from "zustand";
|
|||
|
||||
type SpeakingState = {
|
||||
speakingParticipantIds: Set<number>;
|
||||
lastSpeakingParticipantId: number | null;
|
||||
micGated: boolean;
|
||||
};
|
||||
|
||||
const useSpeakingState = create<SpeakingState>(() => ({
|
||||
speakingParticipantIds: new Set(),
|
||||
lastSpeakingParticipantId: null,
|
||||
micGated: false,
|
||||
}));
|
||||
|
||||
|
|
@ -20,10 +22,19 @@ export function clearSpeakingParticipants() {
|
|||
|
||||
export function removeSpeakingParticipant(participantId: number) {
|
||||
useSpeakingState.setState((state) => {
|
||||
if (!state.speakingParticipantIds.has(participantId)) return state;
|
||||
const wasSpeaking = state.speakingParticipantIds.has(participantId);
|
||||
const wasLastSpeaking = state.lastSpeakingParticipantId === participantId;
|
||||
|
||||
if (!wasSpeaking && !wasLastSpeaking) return state;
|
||||
|
||||
const next = new Set(state.speakingParticipantIds);
|
||||
next.delete(participantId);
|
||||
return { speakingParticipantIds: next };
|
||||
return {
|
||||
speakingParticipantIds: next,
|
||||
lastSpeakingParticipantId: wasLastSpeaking
|
||||
? null
|
||||
: state.lastSpeakingParticipantId,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -31,11 +42,13 @@ export function updateSpeakingParticipants(changed: Map<number, boolean>) {
|
|||
useSpeakingState.setState((state) => {
|
||||
let hasDiff = false;
|
||||
const next = new Set(state.speakingParticipantIds);
|
||||
let lastSpeakingParticipantId = state.lastSpeakingParticipantId;
|
||||
|
||||
for (const [id, speaking] of changed) {
|
||||
if (speaking) {
|
||||
if (!next.has(id)) {
|
||||
next.add(id);
|
||||
lastSpeakingParticipantId = id;
|
||||
hasDiff = true;
|
||||
}
|
||||
} else if (next.has(id)) {
|
||||
|
|
@ -44,10 +57,16 @@ export function updateSpeakingParticipants(changed: Map<number, boolean>) {
|
|||
}
|
||||
}
|
||||
|
||||
return hasDiff ? { speakingParticipantIds: next } : state;
|
||||
return hasDiff
|
||||
? { speakingParticipantIds: next, lastSpeakingParticipantId }
|
||||
: state;
|
||||
});
|
||||
}
|
||||
|
||||
export function useLastSpeakingParticipantId(): number | null {
|
||||
return useSpeakingState((state) => state.lastSpeakingParticipantId);
|
||||
}
|
||||
|
||||
export function useIsSpeaking(participantId: number): boolean {
|
||||
return useSpeakingState((state) =>
|
||||
state.speakingParticipantIds.has(participantId),
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
|
|||
import { useMTP } from "@tensamin/mtp";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { mtp } from "@tensamin/shared/data";
|
||||
import { playSound, stopSound } from "@tensamin/shared/sounds";
|
||||
import { bytesToBase64 } from "mtp";
|
||||
import {
|
||||
deriveCallSecretId,
|
||||
|
|
@ -25,16 +26,16 @@ import {
|
|||
Room,
|
||||
RoomEvent,
|
||||
type RemoteTrack,
|
||||
type ScreenShareCaptureOptions,
|
||||
Track,
|
||||
setLogExtension,
|
||||
getLogger,
|
||||
} from "livekit-client";
|
||||
import z from "zod";
|
||||
import {
|
||||
createScreenShareController,
|
||||
type ScreenShareSession,
|
||||
} from "./screenshare";
|
||||
createMediaShareController,
|
||||
type LocalMediaShareSession,
|
||||
} from "./mediaShare/controller";
|
||||
import type { MediaShareRequest } from "./mediaShare";
|
||||
import {
|
||||
getSpeakingDetector,
|
||||
disposeSpeakingDetector,
|
||||
|
|
@ -100,8 +101,11 @@ type CallStore = {
|
|||
currentCallData: CurrentCallData;
|
||||
deaf: boolean;
|
||||
micEnabled: boolean;
|
||||
cameraEnabled: boolean;
|
||||
screenShareEnabled: boolean;
|
||||
screenShareSession: ScreenShareSession | null;
|
||||
screenShareSession: LocalMediaShareSession | null;
|
||||
cameraSession: LocalMediaShareSession | null;
|
||||
disabledCameraParticipantIds: number[];
|
||||
focusedParticipantId: number | null;
|
||||
focusedParticipantType: "user" | "stream" | null;
|
||||
usersInFocusedViewHidden: boolean;
|
||||
|
|
@ -154,6 +158,8 @@ export function getRoom(): Room {
|
|||
}
|
||||
|
||||
const remoteAudioElements = new Map<string, HTMLMediaElement>();
|
||||
let callJingle: HTMLAudioElement | null = null;
|
||||
let callJingleGeneration = 0;
|
||||
|
||||
const CALL_SECRET_VERSION = 1;
|
||||
const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320;
|
||||
|
|
@ -161,6 +167,31 @@ const SCREEN_SHARE_PREVIEW_MAX_HEIGHT = 180;
|
|||
const SCREEN_SHARE_PREVIEW_QUALITY = 0.7;
|
||||
const SCREEN_SHARE_PREVIEW_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> {
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
|
@ -273,11 +304,21 @@ function matchesRemoteTrackSelector(
|
|||
}
|
||||
|
||||
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)) {
|
||||
publication.setSubscribed(
|
||||
publication.kind === Track.Kind.Audio &&
|
||||
publication.source !== Track.Source.ScreenShareAudio,
|
||||
);
|
||||
const subscribed =
|
||||
publication.source === Track.Source.Camera
|
||||
? !cameraDisabled
|
||||
: publication.source === Track.Source.ScreenShare ||
|
||||
publication.source === Track.Source.ScreenShareAudio
|
||||
? watchesScreen
|
||||
: publication.kind === Track.Kind.Audio;
|
||||
publication.setSubscribed(subscribed);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -567,11 +608,13 @@ export function getRoomMetadata() {
|
|||
|
||||
// Sync local participant flags and screen-share derived state for the active call UI.
|
||||
export function syncParticipantState() {
|
||||
const { screenShareSession } = useCall.getState();
|
||||
const { cameraSession, screenShareSession } = useCall.getState();
|
||||
const room = getRoom();
|
||||
|
||||
useCall.setState({
|
||||
micEnabled: room.localParticipant.isMicrophoneEnabled,
|
||||
cameraEnabled:
|
||||
cameraSession != null || room.localParticipant.isCameraEnabled,
|
||||
screenShareEnabled:
|
||||
screenShareSession != null || room.localParticipant.isScreenShareEnabled,
|
||||
isEncrypted:
|
||||
|
|
@ -695,6 +738,16 @@ export async function sendCallInvite(userId: number) {
|
|||
// Start tracking a participant's shared screen in the call UI.
|
||||
export function startWatchingStream(participantId: number) {
|
||||
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.ScreenShareAudio);
|
||||
|
|
@ -730,6 +783,20 @@ export function setParticipantTrackSubscribed(
|
|||
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.
|
||||
export function focusParticipant(
|
||||
participantId: number,
|
||||
|
|
@ -745,6 +812,17 @@ export function focusParticipant(
|
|||
|
||||
// Stop tracking a participant's shared screen and clean up related UI state.
|
||||
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,
|
||||
|
|
@ -785,9 +863,8 @@ export function stopWatchingFocusedStream() {
|
|||
stopWatchingStream(focusedParticipantId);
|
||||
}
|
||||
|
||||
let screenShareController: ReturnType<
|
||||
typeof createScreenShareController
|
||||
> | null = null;
|
||||
let mediaShareController: ReturnType<typeof createMediaShareController> | null =
|
||||
null;
|
||||
|
||||
function getNoiseFilterAssetBaseUrl() {
|
||||
if (window.location.protocol === "file:") {
|
||||
|
|
@ -797,17 +874,21 @@ function getNoiseFilterAssetBaseUrl() {
|
|||
return "/assets";
|
||||
}
|
||||
|
||||
function getScreenShareController() {
|
||||
if (!screenShareController) {
|
||||
screenShareController = createScreenShareController({
|
||||
function getMediaShareController() {
|
||||
if (!mediaShareController) {
|
||||
mediaShareController = createMediaShareController({
|
||||
room: getRoom(),
|
||||
getState: () => ({
|
||||
screenShareSession: useCall.getState().screenShareSession,
|
||||
cameraSession: useCall.getState().cameraSession,
|
||||
}),
|
||||
setState: (updater) => {
|
||||
useCall.setState((state) =>
|
||||
typeof updater === "function"
|
||||
? updater({ screenShareSession: state.screenShareSession })
|
||||
? updater({
|
||||
screenShareSession: state.screenShareSession,
|
||||
cameraSession: state.cameraSession,
|
||||
})
|
||||
: updater,
|
||||
);
|
||||
},
|
||||
|
|
@ -819,7 +900,7 @@ function getScreenShareController() {
|
|||
});
|
||||
}
|
||||
|
||||
return screenShareController;
|
||||
return mediaShareController;
|
||||
}
|
||||
|
||||
// Connect to LiveKit, enable the microphone, and move the UI into the live call.
|
||||
|
|
@ -866,11 +947,12 @@ export async function connect(callId: string) {
|
|||
|
||||
// Tear down the active call session and return the store to a closed state.
|
||||
export async function disconnect() {
|
||||
stopCallJingle();
|
||||
disposeSpeakingDetector();
|
||||
await clearScreenSharePreview();
|
||||
|
||||
try {
|
||||
await getScreenShareController().clearPublishedScreenShare();
|
||||
await getMediaShareController().clearAll();
|
||||
} catch (error) {
|
||||
log(
|
||||
1,
|
||||
|
|
@ -892,6 +974,9 @@ export async function disconnect() {
|
|||
deaf: false,
|
||||
view: "preview",
|
||||
screenShareSession: null,
|
||||
cameraSession: null,
|
||||
cameraEnabled: false,
|
||||
disabledCameraParticipantIds: [],
|
||||
focusedParticipantId: null,
|
||||
focusedParticipantType: null,
|
||||
usersInFocusedViewHidden: false,
|
||||
|
|
@ -939,6 +1024,10 @@ export async function joinCall(
|
|||
ownCallSecretInvitePending: isNewCall,
|
||||
});
|
||||
|
||||
if (sendInvite && !existingCallId) {
|
||||
void startCallJingle(() => useCall.getState().invitedUserId != null);
|
||||
}
|
||||
|
||||
if (callSecret) {
|
||||
try {
|
||||
if (!existingCallId) {
|
||||
|
|
@ -1015,37 +1104,37 @@ export async function toggleMute() {
|
|||
syncParticipantState();
|
||||
}
|
||||
|
||||
// Start browser-native screen sharing for the current participant.
|
||||
export async function startScreenShare(options?: ScreenShareCaptureOptions) {
|
||||
await getScreenShareController().startScreenShare(options);
|
||||
export async function startScreenShare(
|
||||
request: Omit<MediaShareRequest, "kind"> = {},
|
||||
) {
|
||||
await getMediaShareController().start({ ...request, kind: "screen" });
|
||||
await publishScreenSharePreview();
|
||||
}
|
||||
|
||||
// Start the Linux desktop capture path that renders frames through Tauri.
|
||||
export async function startLinuxDesktopScreenShare(sourceId: string) {
|
||||
await getScreenShareController().startLinuxDesktopScreenShare(sourceId);
|
||||
await publishScreenSharePreview();
|
||||
export async function startCameraShare(sourceId?: string) {
|
||||
await getMediaShareController().start({ kind: "camera", sourceId });
|
||||
}
|
||||
|
||||
// Stop the local participant's active screen share and related previews.
|
||||
export async function stopScreenShare() {
|
||||
await getScreenShareController().stopScreenShare();
|
||||
await getMediaShareController().stop("screen");
|
||||
await clearScreenSharePreview();
|
||||
}
|
||||
|
||||
export async function stopCameraShare() {
|
||||
await getMediaShareController().stop("camera");
|
||||
}
|
||||
|
||||
// Toggle screen sharing on or off from UI controls.
|
||||
export async function setScreenShareEnabled(
|
||||
enabled: boolean,
|
||||
options?: ScreenShareCaptureOptions,
|
||||
request: Omit<MediaShareRequest, "kind"> = {},
|
||||
) {
|
||||
await getScreenShareController().setScreenShareEnabled(enabled, options);
|
||||
|
||||
if (enabled) {
|
||||
await publishScreenSharePreview();
|
||||
await startScreenShare(request);
|
||||
return;
|
||||
}
|
||||
|
||||
await clearScreenSharePreview();
|
||||
await stopScreenShare();
|
||||
}
|
||||
|
||||
// Reset the in-memory call store when leaving the call experience entirely.
|
||||
|
|
@ -1060,7 +1149,10 @@ export function resetCallState() {
|
|||
livekitToken: null,
|
||||
currentCallData: null,
|
||||
deaf: false,
|
||||
cameraEnabled: false,
|
||||
screenShareSession: null,
|
||||
cameraSession: null,
|
||||
disabledCameraParticipantIds: [],
|
||||
focusedParticipantId: null,
|
||||
focusedParticipantType: null,
|
||||
usersInFocusedViewHidden: false,
|
||||
|
|
@ -1114,8 +1206,11 @@ export const useCall = create<CallStore>(() => ({
|
|||
currentCallData: null,
|
||||
deaf: false,
|
||||
micEnabled: false,
|
||||
cameraEnabled: false,
|
||||
screenShareEnabled: false,
|
||||
screenShareSession: null,
|
||||
cameraSession: null,
|
||||
disabledCameraParticipantIds: [],
|
||||
focusedParticipantId: null,
|
||||
focusedParticipantType: null,
|
||||
usersInFocusedViewHidden: false,
|
||||
|
|
@ -1174,12 +1269,14 @@ export function useInitializeCall() {
|
|||
useCall.setState({
|
||||
incomingCallInvite: { callId, callSecret, senderId },
|
||||
});
|
||||
void startCallJingle(() => useCall.getState().incomingCallInvite != null);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const setInvitePopupOpen = useCallback((open: boolean) => {
|
||||
if (!open) {
|
||||
stopCallJingle();
|
||||
useCall.setState({ incomingCallInvite: null });
|
||||
}
|
||||
}, []);
|
||||
|
|
@ -1188,6 +1285,7 @@ export function useInitializeCall() {
|
|||
(accepted: boolean) => {
|
||||
const invite = useCall.getState().incomingCallInvite;
|
||||
|
||||
stopCallJingle();
|
||||
useCall.setState({ incomingCallInvite: null });
|
||||
|
||||
if (!invite) {
|
||||
|
|
@ -1235,6 +1333,11 @@ export function useInitializeCall() {
|
|||
return;
|
||||
}
|
||||
|
||||
const currentCall = useCall.getState();
|
||||
if (currentCall.callId === CallId && currentCall.state !== "closed") {
|
||||
return;
|
||||
}
|
||||
|
||||
showCallingScreen(
|
||||
CallId,
|
||||
normalizeWrappedCallSecret(CallSecret),
|
||||
|
|
@ -1303,6 +1406,7 @@ export function useInitializeCall() {
|
|||
|
||||
const onConnected = async () => {
|
||||
useCall.setState({ state: "open" });
|
||||
playSound("call_join");
|
||||
syncParticipantState();
|
||||
|
||||
const detector = getSpeakingDetector();
|
||||
|
|
@ -1376,6 +1480,8 @@ export function useInitializeCall() {
|
|||
};
|
||||
|
||||
const onDisconnected = () => {
|
||||
stopCallJingle();
|
||||
playSound("call_leave");
|
||||
useCall.setState({ state: "closed" });
|
||||
syncParticipantState();
|
||||
log(2, "call", "purple", "Disconnected from call", {
|
||||
|
|
@ -1385,11 +1491,14 @@ export function useInitializeCall() {
|
|||
};
|
||||
|
||||
const onParticipantConnected = () => {
|
||||
stopCallJingle();
|
||||
playSound("call_join");
|
||||
syncAllRemoteTrackSubscriptions();
|
||||
syncParticipantState();
|
||||
};
|
||||
|
||||
const onParticipantDisconnected = (participant: Participant) => {
|
||||
playSound("call_leave");
|
||||
const participantId = getParticipantId(participant.identity);
|
||||
|
||||
if (participantId != null) {
|
||||
|
|
@ -1416,6 +1525,10 @@ export function useInitializeCall() {
|
|||
};
|
||||
|
||||
const onLocalTrackPublished = (publication: LocalTrackPublication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
playSound("stream_start_self");
|
||||
}
|
||||
|
||||
if (
|
||||
publication.kind === Track.Kind.Audio &&
|
||||
publication.source === Track.Source.Microphone &&
|
||||
|
|
@ -1437,6 +1550,10 @@ export function useInitializeCall() {
|
|||
};
|
||||
|
||||
const onLocalTrackUnpublished = (publication: LocalTrackPublication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
playSound("stream_end_self");
|
||||
}
|
||||
|
||||
if (
|
||||
publication.kind === Track.Kind.Audio &&
|
||||
publication.source === Track.Source.Microphone
|
||||
|
|
@ -1453,17 +1570,22 @@ export function useInitializeCall() {
|
|||
publication: RemoteTrackPublication,
|
||||
participant: RemoteParticipant,
|
||||
) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
playSound("stream_start_other");
|
||||
}
|
||||
|
||||
const participantId = getParticipantId(participant.identity);
|
||||
|
||||
if (participantId != null) {
|
||||
if (
|
||||
publication.kind === Track.Kind.Audio &&
|
||||
publication.source !== Track.Source.ScreenShareAudio
|
||||
) {
|
||||
publication.setSubscribed(true);
|
||||
} else {
|
||||
publication.setSubscribed(false);
|
||||
}
|
||||
syncRemoteParticipantTrackSubscriptions(participantId);
|
||||
}
|
||||
|
||||
onParticipantStateChange();
|
||||
};
|
||||
|
||||
const onTrackUnpublished = (publication: RemoteTrackPublication) => {
|
||||
if (publication.source === Track.Source.ScreenShare) {
|
||||
playSound("stream_end_other");
|
||||
}
|
||||
|
||||
onParticipantStateChange();
|
||||
|
|
@ -1526,7 +1648,7 @@ export function useInitializeCall() {
|
|||
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
||||
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
||||
room.on(RoomEvent.TrackPublished, onTrackPublished);
|
||||
room.on(RoomEvent.TrackUnpublished, onParticipantStateChange);
|
||||
room.on(RoomEvent.TrackUnpublished, onTrackUnpublished);
|
||||
room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
|
||||
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||
room.on(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||
|
|
@ -1547,7 +1669,7 @@ export function useInitializeCall() {
|
|||
room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
|
||||
room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
|
||||
room.off(RoomEvent.TrackPublished, onTrackPublished);
|
||||
room.off(RoomEvent.TrackUnpublished, onParticipantStateChange);
|
||||
room.off(RoomEvent.TrackUnpublished, onTrackUnpublished);
|
||||
room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
|
||||
room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
|
||||
room.off(RoomEvent.TrackMuted, onParticipantStateChange);
|
||||
|
|
@ -1585,7 +1707,7 @@ export function useInitializeCall() {
|
|||
}
|
||||
|
||||
setCurrentCallData({
|
||||
...(data.data as z.infer<typeof mtp.CallData.response>),
|
||||
...mtp.CallData.response.parse(data.data),
|
||||
exists: true,
|
||||
});
|
||||
})
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
import { RoomEvent } from "livekit-client";
|
||||
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCall, getRoom } from "../../store";
|
||||
import { getRoom, setUsersInFocusedViewHidden, useCall } from "../../store";
|
||||
import Base from "../../components/modals/base";
|
||||
import { useIsMobile } from "@methanium/ui";
|
||||
|
||||
const SECONDARY_ROW_HEIGHT_PX = 180;
|
||||
const STACK_GAP_PX = 12;
|
||||
|
||||
export default function View() {
|
||||
const isMobile = useIsMobile();
|
||||
const room = getRoom();
|
||||
const layoutVersion = useCall((state) => state.layoutVersion);
|
||||
|
||||
|
|
@ -27,6 +29,10 @@ export default function View() {
|
|||
const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false);
|
||||
const [participantVersion, setParticipantVersion] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (isMobile) setUsersInFocusedViewHidden(true);
|
||||
}, [isMobile]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncParticipants = () => {
|
||||
setParticipantVersion((version) => version + 1);
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { RoomEvent } from "livekit-client";
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useCall, getRoom } from "../../store";
|
||||
import Base from "../../components/modals/base";
|
||||
import { cn, useIsMobile } from "@methanium/ui";
|
||||
|
||||
const TILE_ASPECT_RATIO = 16 / 9;
|
||||
const GRID_GAP = 12;
|
||||
|
|
@ -214,6 +215,8 @@ export default function View() {
|
|||
return room.getParticipantByIdentity(String(participantId));
|
||||
}
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
|
@ -221,7 +224,7 @@ export default function View() {
|
|||
height: "calc(100% - 2rem)",
|
||||
width: "calc(100% - 2rem)",
|
||||
}}
|
||||
className="overflow-hidden p-3"
|
||||
className={cn("overflow-hidden", isMobile ? "" : "p-3")}
|
||||
>
|
||||
<div className="flex h-full w-full flex-col items-center justify-center gap-3">
|
||||
{rows.map((row) => (
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
triggerCallLayoutCalculation,
|
||||
useCall,
|
||||
} from "../../store";
|
||||
import { useIsMobile } from "@methanium/ui";
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
const screenRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -129,6 +130,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||
setIsImmersiveChromeVisible(false);
|
||||
};
|
||||
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={screenRef}
|
||||
|
|
@ -139,23 +142,25 @@ export default function Layout({ children }: { children: React.ReactNode }) {
|
|||
onMouseMove={showImmersiveChrome}
|
||||
onMouseLeave={hideImmersiveChrome}
|
||||
>
|
||||
<div
|
||||
ref={topBarRef}
|
||||
className={`shrink-0 w-full z-40 transition-all duration-200 ${
|
||||
isImmersiveFocusedView
|
||||
? immersiveChromeVisible
|
||||
? "opacity-100 translate-y-0 pointer-events-auto"
|
||||
: "opacity-0 -translate-y-2 pointer-events-none"
|
||||
: "opacity-100 translate-y-0"
|
||||
}`}
|
||||
style={{
|
||||
position: usersInFocusedViewHidden ? "absolute" : "relative",
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<TopBar />
|
||||
</div>
|
||||
{!isMobile && (
|
||||
<div
|
||||
ref={topBarRef}
|
||||
className={`shrink-0 w-full z-40 transition-all duration-200 ${
|
||||
isImmersiveFocusedView
|
||||
? immersiveChromeVisible
|
||||
? "opacity-100 translate-y-0 pointer-events-auto"
|
||||
: "opacity-0 -translate-y-2 pointer-events-none"
|
||||
: "opacity-100 translate-y-0"
|
||||
}`}
|
||||
style={{
|
||||
position: usersInFocusedViewHidden ? "absolute" : "relative",
|
||||
top: 0,
|
||||
left: 0,
|
||||
}}
|
||||
>
|
||||
<TopBar />
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="min-h-0 flex-1 w-full flex justify-center items-center overflow-hidden"
|
||||
style={
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
- Overlay for stream modals
|
||||
- Mobile
|
||||
- Sounds
|
||||
- Admin call actions
|
||||
- Timeout
|
||||
- Disconnect
|
||||
|
|
@ -8,3 +7,4 @@
|
|||
- Add quality selection
|
||||
- Good preview 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/shared": "workspace:*",
|
||||
"@tensamin/storage": "workspace:*",
|
||||
"@tensamin/ui": "*",
|
||||
"@methanium/ui": "*",
|
||||
"@tensamin/user": "workspace:*",
|
||||
"lucide-react": "^1.14.0",
|
||||
"motion": "^12.42.2",
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button } from "@tensamin/ui";
|
||||
import { Button } from "@methanium/ui";
|
||||
import Emoji from "@tensamin/markdown/emoji";
|
||||
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";
|
||||
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import {
|
|||
TabsContent,
|
||||
TabsList,
|
||||
TabsTrigger,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { Loader2, Search } from "lucide-react";
|
||||
import MediaSaveButton from "./mediaSaveButton";
|
||||
|
|
|
|||
|
|
@ -1,32 +1,27 @@
|
|||
import Input from "@tensamin/markdown/input";
|
||||
import {
|
||||
Avatar,
|
||||
AvatarFallback,
|
||||
AvatarImage,
|
||||
Card,
|
||||
CardHeader,
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
Skeleton,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import { Button } from "@tensamin/ui";
|
||||
import { Button } from "@methanium/ui";
|
||||
|
||||
import { Plus, Laugh, FileVideo, Forward, X } from "lucide-react";
|
||||
import { Plus, Laugh, FileVideo } from "lucide-react";
|
||||
import { useChat, useReplyMessage } from "../context";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import { cn, useIsMobile } from "@tensamin/ui";
|
||||
import { cn, useIsMobile } from "@methanium/ui";
|
||||
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||
|
||||
import { useSession } from "@tensamin/storage/session";
|
||||
import GifPicker from "./gifPicker";
|
||||
import EmojiPicker from "./emojiPicker";
|
||||
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
|
||||
import Wrapper from "@tensamin/user/wrapper";
|
||||
import Text from "@tensamin/markdown/text";
|
||||
import ReplyBox from "./replyBox";
|
||||
|
||||
export default function InputComponent({
|
||||
value,
|
||||
|
|
@ -117,6 +112,7 @@ export default function InputComponent({
|
|||
Content: encryptedContent,
|
||||
ReceiverId: userId,
|
||||
SendTime: time,
|
||||
...(replyTo && { ReplyId: replyTo }),
|
||||
}).catch((e) => {
|
||||
log(0, "Chat", "red", "Failed to send message", e, {
|
||||
ReceiverId: userId,
|
||||
|
|
@ -196,54 +192,15 @@ export default function InputComponent({
|
|||
|
||||
return (
|
||||
<div className="flex flex-col">
|
||||
{/* reply */}
|
||||
{replyTo !== undefined && (
|
||||
<div className="self-center rounded-t-lg bg-background border-t border-x flex items-center justify-start w-[90%] p-1">
|
||||
<div className="flex gap-1 w-full items-center">
|
||||
<Forward
|
||||
color="var(--muted-foreground)"
|
||||
className="opacity-60"
|
||||
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>
|
||||
<ReplyBox
|
||||
content={replyMessage?.Content}
|
||||
loading={!replyMessage}
|
||||
onDismiss={() => setReplyTo(undefined)}
|
||||
userId={replyUserId ?? ownId}
|
||||
variant="composer"
|
||||
/>
|
||||
)}
|
||||
<Card
|
||||
ref={inputBoxRef}
|
||||
|
|
@ -267,7 +224,7 @@ export default function InputComponent({
|
|||
/>
|
||||
<div className="w-full flex justify-between gap-1 p-1 pt-0">
|
||||
<div className="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} />
|
||||
</Button>
|
||||
<div className="w-auto flex text-red-500">
|
||||
|
|
@ -280,15 +237,16 @@ export default function InputComponent({
|
|||
onOpenChange={setEmojiPopoverOpen}
|
||||
>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
render={({ onClick }) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
aria-label="Open emoji picker"
|
||||
className="w-9 h-9 p-0"
|
||||
className="w-9 h-9! p-0"
|
||||
variant="ghost"
|
||||
>
|
||||
<Laugh size={20} />
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<PopoverContent className="w-auto p-0">
|
||||
<EmojiPicker
|
||||
|
|
@ -302,11 +260,15 @@ export default function InputComponent({
|
|||
</Popover>
|
||||
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
|
||||
<PopoverTrigger
|
||||
render={
|
||||
<Button className="w-9 h-9 p-0" variant="ghost">
|
||||
render={({ onClick }) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
className="w-9 h-9! p-0"
|
||||
variant="ghost"
|
||||
>
|
||||
<FileVideo size={20} />
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
<PopoverContent
|
||||
ref={gifPopoverRef}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import Text from "@tensamin/markdown/text";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
|
||||
import { TriangleAlert } from "lucide-react";
|
||||
import { useState, useMemo, useEffect } from "react";
|
||||
import MediaSaveButton from "./mediaSaveButton";
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { Button, cn } from "@tensamin/ui";
|
||||
import { Button, cn } from "@methanium/ui";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { Save } from "lucide-react";
|
||||
import { useEffect, useState } from "react";
|
||||
|
|
|
|||
|
|
@ -1,8 +1,7 @@
|
|||
import * as React from "react";
|
||||
import type { RawMessage } from "../values";
|
||||
import Text from "@tensamin/markdown/text";
|
||||
import { AlertTriangle, Check, Ellipse, RefreshCw } from "lucide-react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
|
||||
import { memo, useCallback, useEffect, useState } from "react";
|
||||
import type { User } from "@tensamin/user/context";
|
||||
|
||||
import {
|
||||
|
|
@ -12,17 +11,19 @@ import {
|
|||
Button,
|
||||
cn,
|
||||
Skeleton,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import MessageContextMenu from "./messageContextMenu";
|
||||
import Media from "./media";
|
||||
import { useStorage } from "@tensamin/storage/context";
|
||||
import { useMTP } from "@tensamin/mtp";
|
||||
import Input from "@tensamin/markdown/input";
|
||||
import { useChat } from "../context";
|
||||
import { encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||
import { getMessage, useChat } from "../context";
|
||||
import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
|
||||
import { log, toast } from "@tensamin/shared/log";
|
||||
import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
|
||||
import { useRecordEmojiUse } from "./emojiRanks";
|
||||
import { useUser } from "@tensamin/user/context";
|
||||
import ReplyBox from "./replyBox";
|
||||
|
||||
function MessageComponent({
|
||||
grouped,
|
||||
|
|
@ -142,7 +143,45 @@ function MessageComponent({
|
|||
userId,
|
||||
deleteMessage,
|
||||
removeReaction,
|
||||
replyTo,
|
||||
} = 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 [editing, setEditing] = useState(false);
|
||||
const [editDraft, setEditDraft] = useState(message.Content);
|
||||
|
|
@ -233,8 +272,16 @@ function MessageComponent({
|
|||
return (
|
||||
<div
|
||||
// pt-3 is to get a gap between messages
|
||||
className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${opacityClass}`}
|
||||
className={`${grouped ? "" : "pt-3"} w-full flex flex-col gap-1 justify-start items-start transition-opacity duration-150 ${opacityClass}`}
|
||||
>
|
||||
{/* reply */}
|
||||
{replyMessage && replyUser && (
|
||||
<ReplyBox
|
||||
content={replyMessage.Content}
|
||||
user={replyUser}
|
||||
variant="message"
|
||||
/>
|
||||
)}
|
||||
{user && message.Content ? (
|
||||
<MessageContextMenu
|
||||
content={message.Content}
|
||||
|
|
@ -244,138 +291,149 @@ function MessageComponent({
|
|||
onReact={toggleReaction}
|
||||
onSetEditing={setEditing}
|
||||
>
|
||||
<div
|
||||
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",
|
||||
{
|
||||
"bg-(--destructive)/10 text-destructive-foreground hover:bg-(--destructive)/15":
|
||||
actuallyFailed,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<>
|
||||
{grouped ? (
|
||||
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
||||
{new Date(message.SendTime).toLocaleString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<Avatar className="mr-1 mb-auto mt-1 w-10">
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback>
|
||||
{user.Display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"group hover:bg-muted/50 select-text! relative justify-start flex gap-1 items-center w-full px-2 whitespace-pre-wrap break-all transition-colors duration-200 ease-in-out",
|
||||
{
|
||||
"bg-(--destructive)/10 text-destructive-foreground hover:bg-(--destructive)/15":
|
||||
actuallyFailed,
|
||||
"bg-(--primary-foreground-alt)/15 hover:bg-(--primary-foreground-alt)/20":
|
||||
replyTo === message.SendTime && !actuallyFailed,
|
||||
},
|
||||
)}
|
||||
<div
|
||||
className={cn("flex flex-col group", {
|
||||
"min-w-0 flex-1": editing,
|
||||
})}
|
||||
>
|
||||
{!grouped && (
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-medium">{user.Display}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(message.SendTime).toLocaleString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
{message.MessageState === "read" ? (
|
||||
<Check
|
||||
size={12}
|
||||
color="var(--primary-foreground-alt)"
|
||||
/>
|
||||
) : message.MessageState === "received" ? (
|
||||
<Check size={12} color="var(--muted-foreground)" />
|
||||
) : message.MessageState === "sent" ? (
|
||||
<RefreshCw size={12} color="var(--muted-foreground)" />
|
||||
) : message.MessageState === "sending" ? (
|
||||
<Ellipse size={12} color="var(--muted-foreground)" />
|
||||
) : null}
|
||||
{actuallyFailed && (
|
||||
<AlertTriangle color="var(--destructive)" size={12} />
|
||||
>
|
||||
<>
|
||||
{grouped ? (
|
||||
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
|
||||
{new Date(message.SendTime).toLocaleString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
) : (
|
||||
<Avatar className="mr-1 mb-auto mt-1 w-10">
|
||||
<AvatarImage src={user.Avatar} />
|
||||
<AvatarFallback>
|
||||
{user.Display.slice(0, 2).toUpperCase()}
|
||||
</AvatarFallback>
|
||||
</Avatar>
|
||||
)}
|
||||
<div
|
||||
className={cn("flex flex-col group", {
|
||||
"min-w-0 flex-1": editing,
|
||||
})}
|
||||
>
|
||||
{!grouped && (
|
||||
<div className="flex items-center gap-1">
|
||||
<p className="font-semibold">{user.Display}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{new Date(message.SendTime).toLocaleString([], {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
})}
|
||||
</p>
|
||||
<div className="flex items-center justify-start gap-1">
|
||||
{message.SenderId !==
|
||||
ownId ? null : message.MessageState === "read" ? (
|
||||
<Check
|
||||
size={12}
|
||||
color="var(--primary-foreground-alt)"
|
||||
/>
|
||||
) : message.MessageState === "received" ? (
|
||||
<CheckLine
|
||||
size={12}
|
||||
color="var(--muted-foreground)"
|
||||
/>
|
||||
) : message.MessageState === "sent" ? (
|
||||
<Check size={12} color="var(--muted-foreground)" />
|
||||
) : message.MessageState === "sending" ? (
|
||||
<RefreshCw
|
||||
size={12}
|
||||
color="var(--muted-foreground)"
|
||||
/>
|
||||
) : null}
|
||||
{actuallyFailed && (
|
||||
<AlertTriangle color="var(--destructive)" size={12} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
<div className="flex w-full flex-col gap-1">
|
||||
<Input
|
||||
className="w-full"
|
||||
styled
|
||||
setValue={setEditDraft}
|
||||
value={editDraft}
|
||||
onSubmit={() => {
|
||||
submitEditMessage(editDraft);
|
||||
setEditing(false);
|
||||
}}
|
||||
/>
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
className="text-primary-foreground-alt"
|
||||
onClick={() => {
|
||||
if (editDraft === message.Content) {
|
||||
deleteMessage(message.SendTime);
|
||||
} else {
|
||||
submitEditMessage(editDraft);
|
||||
}
|
||||
setEditDraft(message.Content);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Save
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="link"
|
||||
className="text-muted-foreground"
|
||||
onClick={() => {
|
||||
setEditDraft(message.Content);
|
||||
setEditing(false);
|
||||
}}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : isValidURL ? (
|
||||
<Media link={message.Content} />
|
||||
) : (
|
||||
<Text value={message.Content} />
|
||||
)}
|
||||
{groupedReactions.length > 0 && (
|
||||
<div className="mt-1 flex flex-wrap gap-1 pb-1">
|
||||
{groupedReactions.map(
|
||||
([reaction, { count, reactedByMe }]) => (
|
||||
<Button
|
||||
key={reaction}
|
||||
aria-label={`${reactedByMe ? "Remove" : "Add"} ${reaction} reaction`}
|
||||
className={cn(
|
||||
"h-7 gap-2 rounded-lg py-3.5 px-1.5! border",
|
||||
reactedByMe
|
||||
? "border-(--primary-foreground-alt)/40!"
|
||||
: "",
|
||||
)}
|
||||
onClick={() => void toggleReaction(reaction)}
|
||||
size="xs"
|
||||
variant={reactedByMe ? "subtleDefault" : "outline"}
|
||||
>
|
||||
<Emoji className="h-5 w-5" shortcode={reaction} />
|
||||
<span className="text-sm">{count}</span>
|
||||
</Button>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{editing ? (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
</>
|
||||
</MessageContextMenu>
|
||||
) : (
|
||||
<Skeleton className="h-10 w-30" />
|
||||
|
|
@ -384,11 +442,12 @@ function MessageComponent({
|
|||
);
|
||||
}
|
||||
|
||||
export default React.memo(MessageComponent, (prev, next) => {
|
||||
export default memo(MessageComponent, (prev, next) => {
|
||||
return (
|
||||
prev.message.SendTime === next.message.SendTime &&
|
||||
prev.message.Content === next.message.Content &&
|
||||
prev.message.SenderId === next.message.SenderId &&
|
||||
prev.message.ReplyId === next.message.ReplyId &&
|
||||
prev.message.MessageState === next.message.MessageState &&
|
||||
prev.message.failed === next.message.failed &&
|
||||
prev.message.decryptionFailed === next.message.decryptionFailed &&
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ import {
|
|||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
useIsMobile,
|
||||
} from "@tensamin/ui";
|
||||
} from "@methanium/ui";
|
||||
import {
|
||||
Clipboard,
|
||||
Ellipsis,
|
||||
|
|
@ -35,11 +35,13 @@ import {
|
|||
Reply,
|
||||
Trash,
|
||||
} from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import { cloneElement, useMemo, useState, useSyncExternalStore } from "react";
|
||||
import type {
|
||||
MouseEvent as ReactMouseEvent,
|
||||
ReactElement,
|
||||
ReactNode,
|
||||
Ref,
|
||||
} from "react";
|
||||
import { useChat } from "../context";
|
||||
import Emoji from "@tensamin/markdown/emoji";
|
||||
|
|
@ -260,18 +262,32 @@ function MiniMenuTooltip({
|
|||
children,
|
||||
label,
|
||||
}: {
|
||||
children: ReactElement;
|
||||
children: ReactElement<{
|
||||
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
|
||||
ref?: Ref<HTMLElement>;
|
||||
}>;
|
||||
label: string;
|
||||
}) {
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger render={children} />
|
||||
<TooltipTrigger
|
||||
render={({ ref, onClick }) =>
|
||||
cloneElement(children, {
|
||||
ref,
|
||||
onClick: (event) => {
|
||||
onClick?.(event);
|
||||
children.props.onClick?.(event);
|
||||
},
|
||||
})
|
||||
}
|
||||
/>
|
||||
<TooltipContent>{label}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniMessageMenu({
|
||||
fadeOut,
|
||||
isOwnMessage,
|
||||
onDelete,
|
||||
onEdit,
|
||||
|
|
@ -283,6 +299,7 @@ function MiniMessageMenu({
|
|||
onReply,
|
||||
shiftIsPressed,
|
||||
}: {
|
||||
fadeOut: boolean;
|
||||
isOwnMessage: boolean;
|
||||
onDelete: () => void;
|
||||
onEdit: () => void;
|
||||
|
|
@ -295,8 +312,12 @@ function MiniMessageMenu({
|
|||
shiftIsPressed: boolean;
|
||||
}) {
|
||||
return (
|
||||
<div className="absolute right-4 -top-3 z-10">
|
||||
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-0! shadow-lg">
|
||||
<motion.div
|
||||
className="absolute right-4 -top-3 z-10"
|
||||
exit={fadeOut ? { opacity: 0 } : undefined}
|
||||
transition={fadeOut ? { duration: 0.048 } : undefined}
|
||||
>
|
||||
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-px! shadow-lg">
|
||||
{quickReactions.map((emoji) => (
|
||||
<MiniMenuTooltip
|
||||
key={emoji}
|
||||
|
|
@ -319,15 +340,16 @@ function MiniMessageMenu({
|
|||
children={
|
||||
usePickerTrigger ? (
|
||||
<PopoverTrigger
|
||||
render={
|
||||
render={({ onClick }) => (
|
||||
<Button
|
||||
onClick={onClick}
|
||||
aria-label="Add reaction"
|
||||
variant="ghost"
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<Laugh />
|
||||
</Button>
|
||||
}
|
||||
)}
|
||||
/>
|
||||
) : (
|
||||
<Button
|
||||
|
|
@ -397,7 +419,7 @@ function MiniMessageMenu({
|
|||
/>
|
||||
)}
|
||||
</Card>
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -538,16 +560,25 @@ export default function MessageContextMenu({
|
|||
[],
|
||||
);
|
||||
const [mainDrawerOpen, setMainDrawerOpen] = useState(false);
|
||||
const [contextMenuOpen, setContextMenuOpen] = useState(false);
|
||||
const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false);
|
||||
const [pickerOpen, setPickerOpen] = useState(false);
|
||||
const [pickerClosing, setPickerClosing] = useState(false);
|
||||
const [fadeMiniMenu, setFadeMiniMenu] = useState(false);
|
||||
const { ranks } = useEmojiRanks();
|
||||
const quickReactions = getRecentEmojis(ranks, 3);
|
||||
const menuReactions = getRecentEmojis(ranks, 5);
|
||||
|
||||
function selectReaction(emoji: string) {
|
||||
void onReact(emoji);
|
||||
setActiveMiniMenu(null);
|
||||
setReactionDrawerOpen(false);
|
||||
setPickerOpen(false);
|
||||
setPickerClosing(false);
|
||||
}
|
||||
function setPickerVisibility(open: boolean) {
|
||||
setPickerOpen(open);
|
||||
setPickerClosing(!isMobile && !open);
|
||||
}
|
||||
const mainDrawerComponents = useMemo(
|
||||
() =>
|
||||
|
|
@ -578,20 +609,28 @@ export default function MessageContextMenu({
|
|||
onPointerLeave={scheduleMiniMenuClose}
|
||||
>
|
||||
{children}
|
||||
{!isMobile && !hideMiniMenu && activeMenuId === messageId && (
|
||||
<MiniMessageMenu
|
||||
isOwnMessage={isOwnMessage}
|
||||
onDelete={() => deleteMessage(messageId)}
|
||||
onEdit={() => onSetEditing(true)}
|
||||
onOpenMenu={openMenu}
|
||||
onOpenPicker={() => setPickerOpen(true)}
|
||||
usePickerTrigger={!isMobile}
|
||||
onReact={selectReaction}
|
||||
quickReactions={quickReactions}
|
||||
onReply={() => setReplyTo(messageId)}
|
||||
shiftIsPressed={shiftIsPressed}
|
||||
/>
|
||||
)}
|
||||
<AnimatePresence onExitComplete={() => setFadeMiniMenu(false)}>
|
||||
{!isMobile &&
|
||||
!hideMiniMenu &&
|
||||
(activeMenuId === messageId ||
|
||||
contextMenuOpen ||
|
||||
pickerOpen ||
|
||||
pickerClosing) && (
|
||||
<MiniMessageMenu
|
||||
fadeOut={fadeMiniMenu}
|
||||
isOwnMessage={isOwnMessage}
|
||||
onDelete={() => deleteMessage(messageId)}
|
||||
onEdit={() => onSetEditing(true)}
|
||||
onOpenMenu={openMenu}
|
||||
onOpenPicker={() => setPickerVisibility(true)}
|
||||
usePickerTrigger={!isMobile}
|
||||
onReact={selectReaction}
|
||||
quickReactions={quickReactions}
|
||||
onReply={() => setReplyTo(messageId)}
|
||||
shiftIsPressed={shiftIsPressed}
|
||||
/>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -636,14 +675,14 @@ export default function MessageContextMenu({
|
|||
emojis={menuReactions}
|
||||
onMore={() => {
|
||||
setReactionDrawerOpen(false);
|
||||
setPickerOpen(true);
|
||||
setPickerVisibility(true);
|
||||
}}
|
||||
onSelect={selectReaction}
|
||||
/>
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
<Drawer open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<Drawer open={pickerOpen} onOpenChange={setPickerVisibility}>
|
||||
<DrawerContent>
|
||||
<DrawerTitle className="sr-only">Choose an emoji</DrawerTitle>
|
||||
<DrawerDescription className="sr-only">
|
||||
|
|
@ -672,8 +711,17 @@ export default function MessageContextMenu({
|
|||
});
|
||||
|
||||
return (
|
||||
<Popover open={pickerOpen} onOpenChange={setPickerOpen}>
|
||||
<ContextMenu>
|
||||
<Popover
|
||||
open={pickerOpen}
|
||||
onOpenChange={(open, eventDetails) => {
|
||||
setPickerVisibility(open);
|
||||
setFadeMiniMenu(!open && eventDetails.reason === "outside-press");
|
||||
}}
|
||||
onOpenChangeComplete={(open) => {
|
||||
if (!open) setPickerClosing(false);
|
||||
}}
|
||||
>
|
||||
<ContextMenu onOpenChange={setContextMenuOpen}>
|
||||
<ContextMenuTrigger render={child} />
|
||||
<MessageMenuContent
|
||||
components={desktopMenuComponents}
|
||||
|
|
@ -681,7 +729,7 @@ export default function MessageContextMenu({
|
|||
devEnabled={devEnabled}
|
||||
isOwnMessage={isOwnMessage}
|
||||
messageId={messageId}
|
||||
onAddReaction={() => setPickerOpen(true)}
|
||||
onAddReaction={() => setPickerVisibility(true)}
|
||||
onReact={selectReaction}
|
||||
reactionEmojis={menuReactions}
|
||||
onSetEditing={onSetEditing}
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue