Merge pull request '(feat): big call and chatting stuff' (#23) from dev into main
Some checks failed
/ build-web (push) Successful in 13m34s
/ build-desktop (linux) (push) Successful in 20m28s
/ build-mobile (push) Successful in 33m20s
/ release (push) Failing after 3m48s

Reviewed-on: #23
This commit is contained in:
Alois 2026-08-01 02:05:39 +02:00
commit aee2a0963d
153 changed files with 11308 additions and 5939 deletions

View file

@ -18,6 +18,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -42,6 +45,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#tauri --command pnpm install --frozen-lockfile run: nix develop .#tauri --command pnpm install --frozen-lockfile
@ -128,6 +134,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -180,6 +189,9 @@ jobs:
with: with:
fetch-depth: 0 fetch-depth: 0
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: nix develop .#electron --command pnpm install --frozen-lockfile

View file

@ -19,6 +19,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -43,6 +46,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#tauri --command pnpm install --frozen-lockfile run: nix develop .#tauri --command pnpm install --frozen-lockfile
@ -129,6 +135,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: nix develop .#electron --command pnpm install --frozen-lockfile
@ -177,6 +186,9 @@ jobs:
- name: Check out repo - name: Check out repo
uses: https://data.forgejo.org/actions/checkout@v4 uses: https://data.forgejo.org/actions/checkout@v4
- name: Pull git submodules
run: git submodule update --init --recursive
- name: Install dependencies - name: Install dependencies
run: nix develop .#electron --command pnpm install --frozen-lockfile run: nix develop .#electron --command pnpm install --frozen-lockfile

2
.gitignore vendored
View file

@ -2,3 +2,5 @@ node_modules
releases releases
.fallow .fallow
.direnv .direnv
keystore.jks
keystore.properties

3
.gitmodules vendored Normal file
View file

@ -0,0 +1,3 @@
[submodule "mtp-type-maps"]
path = mtp-type-maps
url = https://git.methanium.net/tensamin/mtp-type-maps

25
LICENSE
View file

@ -1,16 +1,15 @@
Copyright (c) [2025] [Methanium] Copyright (c) 2025 Methanium
All rights reserved. All rights reserved.
This software is protected by copyright. Copying, editing, No part of this software, source code, documentation, or
distributing, publicly performing, or any other use of this software associated materials may be copied, reproduced, modified,
or its components, in source or binary form, is strictly prohibited without the express distributed, published, sublicensed, sold, or used to create
written permission of the copyright holder. derivative works without prior written permission from the
copyright holder.
FUTURE LICENSE ACCEPTANCE: THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY
It is the copyright holder's intention to release this software in the future OF ANY KIND, EXPRESS OR IMPLIED. TO THE MAXIMUM
under a license yet to be defined, which will, among other things, EXTENT PERMITTED BY LAW, THE COPYRIGHT HOLDER SHALL
allow private, non-commercial use. This statement does not constitute NOT BE LIABLE FOR ANY CLAIM, DAMAGES, OR OTHER
a current license grant and does not alter the above LIABILITY ARISING FROM THE SOFTWARE OR ITS USE.
prohibition on use, copying, or modification. Until the formal
publication of such a future license, all rights remain
reserved.

View file

7
TODO Normal file
View 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

View file

@ -95,7 +95,11 @@
"target": [ "target": [
"dmg" "dmg"
], ],
"icon": "build/icons/icon.icns" "icon": "build/icons/icon.icns",
"extendInfo": {
"NSCameraUsageDescription": "Tensamin uses your camera when you choose to share it in a call.",
"NSMicrophoneUsageDescription": "Tensamin uses your microphone for calls."
}
}, },
"publish": null "publish": null
} }

View file

@ -30,12 +30,22 @@ const verbose = process.argv.includes("--verbose");
let mainWindow: BrowserWindow | null = null; let mainWindow: BrowserWindow | null = null;
let selectedScreenShareSourceId: string | null = null; let selectedScreenShareSourceId: string | null = null;
app.setName("tensamin");
app.setPath("userData", join(app.getPath("appData"), "tensamin", "electron"));
if (verbose) { if (verbose) {
app.commandLine.appendSwitch("enable-logging", "stderr"); app.commandLine.appendSwitch("enable-logging", "stderr");
app.commandLine.appendSwitch("v", "1"); app.commandLine.appendSwitch("v", "1");
app.commandLine.appendSwitch("log-level", "0"); app.commandLine.appendSwitch("log-level", "0");
} }
if (
process.platform === "linux" &&
!app.commandLine.hasSwitch("password-store")
) {
app.commandLine.appendSwitch("password-store", "gnome-libsecret");
}
if ( if (
process.platform === "linux" && process.platform === "linux" &&
process.env.XDG_SESSION_TYPE === "wayland" && process.env.XDG_SESSION_TYPE === "wayland" &&
@ -170,6 +180,29 @@ function registerDisplayMediaHandler() {
); );
} }
function registerMediaPermissionHandler() {
const isTrustedRenderer = (url: string) => {
try {
const parsed = new URL(url);
return parsed.protocol === "file:";
} catch {
return false;
}
};
session.defaultSession.setPermissionCheckHandler(
(_webContents, permission, requestingOrigin) =>
permission === "media" && isTrustedRenderer(requestingOrigin),
);
session.defaultSession.setPermissionRequestHandler(
(_webContents, permission, callback, details) => {
callback(
permission === "media" && isTrustedRenderer(details.requestingUrl),
);
},
);
}
function registerIpc() { function registerIpc() {
verboseLog("registering ipc handlers"); verboseLog("registering ipc handlers");
@ -350,6 +383,7 @@ async function start() {
verboseLog("app ready"); verboseLog("app ready");
registerIpc(); registerIpc();
registerDisplayMediaHandler(); registerDisplayMediaHandler();
registerMediaPermissionHandler();
initTray(() => mainWindow); initTray(() => mainWindow);
await createWindow(); await createWindow();
} }

View file

@ -34,10 +34,6 @@ function validateValue(value: unknown): asserts value is string {
} }
export function getSecureStorageStatus(): DesktopSecureStorageStatus { export function getSecureStorageStatus(): DesktopSecureStorageStatus {
if (!safeStorage.isEncryptionAvailable()) {
return { available: false, backend: null };
}
const backend = const backend =
process.platform === "linux" process.platform === "linux"
? safeStorage.getSelectedStorageBackend() ? safeStorage.getSelectedStorageBackend()
@ -48,7 +44,9 @@ export function getSecureStorageStatus(): DesktopSecureStorageStatus {
: null; : null;
return { return {
available: process.platform !== "linux" || backend !== "basic_text", available:
safeStorage.isEncryptionAvailable() &&
(process.platform !== "linux" || backend !== "basic_text"),
backend, backend,
}; };
} }

View file

@ -8,14 +8,12 @@ const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename); const __dirname = path.dirname(__filename);
function getTrayIconPath(filename: string) { function getTrayIconPath(filename: string) {
if (app.isPackaged) return path.join(process.resourcesPath, "icons", filename); if (app.isPackaged)
return path.join(process.resourcesPath, "icons", filename);
return path.resolve(__dirname, "../../build/icons", filename); return path.resolve(__dirname, "../../build/icons", filename);
} }
export function setTrayCallStatus( export function setTrayCallStatus(inCall: boolean, iconDataUrl?: string) {
inCall: boolean,
iconDataUrl?: string,
) {
if (!tray) return; if (!tray) return;
if (inCall && iconDataUrl) { if (inCall && iconDataUrl) {

View file

@ -24,6 +24,3 @@ dist-ssr
*.sw? *.sw?
.android .android
/src-tauri/gen/android/keystore.properties
/src-tauri/gen/android/keystore.jks

View file

@ -4,10 +4,6 @@
"version": "0.0.0", "version": "0.0.0",
"type": "module", "type": "module",
"exports": { "exports": {
"./controls": {
"types": "./src/windowControls.tsx",
"default": "./src/windowControls.tsx"
},
"./deeplinkHandler": { "./deeplinkHandler": {
"types": "./src/deeplinkHandler.tsx", "types": "./src/deeplinkHandler.tsx",
"default": "./src/deeplinkHandler.tsx" "default": "./src/deeplinkHandler.tsx"
@ -35,7 +31,7 @@
"@tauri-apps/plugin-log": "~2", "@tauri-apps/plugin-log": "~2",
"@tauri-apps/plugin-notification": "~2", "@tauri-apps/plugin-notification": "~2",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/ui": "*", "@methanium/ui": "*",
"react": "^19.2.0", "react": "^19.2.0",
"react-dom": "^19.2.0" "react-dom": "^19.2.0"
}, },

View file

@ -1,6 +1,12 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PROJECTION" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- AndroidTV support --> <!-- AndroidTV support -->
<uses-feature android:name="android.software.leanback" android:required="false" /> <uses-feature android:name="android.software.leanback" android:required="false" />
@ -39,6 +45,11 @@
<!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. --> <!-- DEEP LINK PLUGIN. AUTO-GENERATED. DO NOT REMOVE. -->
</activity> </activity>
<service
android:name=".MediaProjectionService"
android:exported="false"
android:foregroundServiceType="mediaProjection" />
<provider <provider
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider" android:authorities="${applicationId}.fileprovider"

View file

@ -1,20 +1,66 @@
package net.tensamin.client package net.tensamin.client
import android.Manifest
import android.app.Activity
import android.content.pm.PackageManager
import android.graphics.Rect import android.graphics.Rect
import android.media.projection.MediaProjectionManager
import android.os.Bundle import android.os.Bundle
import android.view.ViewGroup import android.view.ViewGroup
import android.view.ViewTreeObserver import android.view.ViewTreeObserver
import android.view.WindowManager import android.view.WindowManager
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.widget.FrameLayout import android.widget.FrameLayout
import androidx.activity.result.contract.ActivityResultContracts
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat import androidx.core.view.WindowInsetsCompat
import org.json.JSONObject
class MainActivity : TauriActivity() { class MainActivity : TauriActivity() {
private var contentRoot: FrameLayout? = null private var contentRoot: FrameLayout? = null
private var contentChild: android.view.View? = null private var contentChild: android.view.View? = null
private var previousUsableHeight = 0 private var previousUsableHeight = 0
private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null private var attachLayoutListener: ViewTreeObserver.OnGlobalLayoutListener? = null
private var mediaWebView: WebView? = null
private var pendingScreenAudio: Boolean? = null
private val screenCaptureLauncher = registerForActivityResult(
ActivityResultContracts.StartActivityForResult(),
) { result ->
val includeAudio = pendingScreenAudio
pendingScreenAudio = null
if (includeAudio == null) return@registerForActivityResult
val data = result.data
if (result.resultCode != Activity.RESULT_OK || data == null) {
MobileMediaEvents.emitError("Screen capture permission was denied")
return@registerForActivityResult
}
MediaProjectionService.start(this, result.resultCode, data, includeAudio)
}
private val cameraPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestMultiplePermissions(),
) {
emitCameraPermission()
}
private val screenAudioPermissionLauncher = registerForActivityResult(
ActivityResultContracts.RequestPermission(),
) {
launchScreenCaptureIntent()
}
override fun onWebViewCreate(webView: WebView) {
webView.setInitialScale(300)
mediaWebView = webView
MobileMediaEvents.attach(webView)
webView.addJavascriptInterface(MobileMediaJavascriptInterface(), "tensaminMobileMedia")
}
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
WindowCompat.setDecorFitsSystemWindows(window, true) WindowCompat.setDecorFitsSystemWindows(window, true)
@ -30,9 +76,84 @@ class MainActivity : TauriActivity() {
attachLayoutListener = null attachLayoutListener = null
contentRoot = null contentRoot = null
contentChild = null contentChild = null
mediaWebView?.removeJavascriptInterface("tensaminMobileMedia")
mediaWebView = null
MobileMediaEvents.detach()
super.onDestroy() super.onDestroy()
} }
private fun startScreenShare(includeAudio: Boolean) {
runOnUiThread {
if (pendingScreenAudio != null) {
MobileMediaEvents.emitError("Screen capture permission is already pending")
return@runOnUiThread
}
pendingScreenAudio = includeAudio
if (
includeAudio &&
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) !=
PackageManager.PERMISSION_GRANTED
) {
screenAudioPermissionLauncher.launch(Manifest.permission.RECORD_AUDIO)
} else {
launchScreenCaptureIntent()
}
}
}
private fun launchScreenCaptureIntent() {
val manager = getSystemService(MediaProjectionManager::class.java)
screenCaptureLauncher.launch(manager.createScreenCaptureIntent())
}
private fun stopScreenShare() {
runOnUiThread {
pendingScreenAudio = null
MediaProjectionService.stop(this)
}
}
private fun requestCameraPermission() {
runOnUiThread {
cameraPermissionLauncher.launch(
arrayOf(Manifest.permission.CAMERA, Manifest.permission.RECORD_AUDIO),
)
}
}
private fun emitCameraPermission() {
val detail = JSONObject()
.put(
"camera",
ContextCompat.checkSelfPermission(this, Manifest.permission.CAMERA) ==
PackageManager.PERMISSION_GRANTED,
)
.put(
"microphone",
ContextCompat.checkSelfPermission(this, Manifest.permission.RECORD_AUDIO) ==
PackageManager.PERMISSION_GRANTED,
)
MobileMediaEvents.emit("tensamin-mobile-camera-permission", detail)
}
private inner class MobileMediaJavascriptInterface {
@JavascriptInterface
fun startScreenShare(includeAudio: Boolean) {
this@MainActivity.startScreenShare(includeAudio)
}
@JavascriptInterface
fun stopScreenShare() {
this@MainActivity.stopScreenShare()
}
@JavascriptInterface
fun requestCameraPermission() {
this@MainActivity.requestCameraPermission()
}
}
private fun installKeyboardResizeWorkaround() { private fun installKeyboardResizeWorkaround() {
val content = window.decorView.findViewById<FrameLayout>(android.R.id.content) val content = window.decorView.findViewById<FrameLayout>(android.R.id.content)
contentRoot = content contentRoot = content

View file

@ -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),
)
}
}
}

View file

@ -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) }
}
}

View file

@ -7,7 +7,7 @@ import {
} from "react"; } from "react";
import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link"; import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@tensamin/ui"; import { useIsMobile } from "@methanium/ui";
type DeeplinkContextValue = { type DeeplinkContextValue = {
deeplinks: readonly string[]; deeplinks: readonly string[];

View file

@ -3,7 +3,7 @@ import {
Format, Format,
requestPermissions, requestPermissions,
} from "@tauri-apps/plugin-barcode-scanner"; } from "@tauri-apps/plugin-barcode-scanner";
import { Button } from "@tensamin/ui"; import { Button } from "@methanium/ui";
import { toast } from "@tensamin/shared/log"; import { toast } from "@tensamin/shared/log";

View file

@ -60,9 +60,10 @@
"@tensamin/mtp": "workspace:*", "@tensamin/mtp": "workspace:*",
"@tensamin/tauth": "workspace:*", "@tensamin/tauth": "workspace:*",
"@tensamin/markdown": "workspace:*", "@tensamin/markdown": "workspace:*",
"@tensamin/ui": "*", "@methanium/ui": "*",
"@tensamin/user": "workspace:*", "@tensamin/user": "workspace:*",
"@tensamin/notifications": "workspace:*", "@tensamin/notifications": "workspace:*",
"@tensamin/onboarding": "workspace:*",
"aria-hidden": "^1.2.4", "aria-hidden": "^1.2.4",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -6,9 +6,9 @@ import {
Tooltip, Tooltip,
TooltipTrigger, TooltipTrigger,
TooltipContent, TooltipContent,
} from "@tensamin/ui"; Button,
import { Card, CardHeader } from "@tensamin/ui"; } from "@methanium/ui";
import { Skeleton } from "@tensamin/ui"; import { Skeleton } from "@methanium/ui";
import { getStatusColor } from "@tensamin/shared/data"; import { getStatusColor } from "@tensamin/shared/data";
export function Basic({ export function Basic({
@ -18,42 +18,50 @@ export function Basic({
user: User; user: User;
extra?: React.ReactNode; extra?: React.ReactNode;
}) { }) {
const display = user.Display || user.Username || "Unknown";
const onlineStatus = user.OnlineStatus || "user_borked";
const avatar = user.Avatar
? `data:image/webp;base64,${user.Avatar}`
: undefined;
return ( return (
<Card className="animate-in fade-in duration-300 rounded-xl py-0 h-12.5!"> <Button
<CardHeader className="flex flex-row gap-2.5 items-center justify-start p-2"> render={<div />}
<div className="relative shrink-0 overflow-visible"> nativeButton={false}
<Avatar> variant="outline"
<AvatarImage src={user.Avatar} /> className="outline-none! animate-in fade-in duration-300 h-auto w-full justify-start gap-2.5 rounded-xl min-h-12.5!"
<AvatarFallback> >
{user.Display.slice(0, 2).toUpperCase()} <div className="relative shrink-0 overflow-visible">
</AvatarFallback> <Avatar>
</Avatar> <AvatarImage src={avatar} />
<Tooltip> <AvatarFallback>{display.slice(0, 2).toUpperCase()}</AvatarFallback>
<TooltipTrigger </Avatar>
render={ <Tooltip>
<div className="absolute -bottom-0.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card"> <TooltipTrigger
<div render={
style={{ <div className="absolute -bottom-0.5 -right-0.5 z-10 flex h-3.5 w-3.5 items-center justify-center rounded-full bg-card">
backgroundColor: getStatusColor(user.OnlineStatus), <div
}} style={{
className="h-2.25 w-2.25 rounded-full" backgroundColor: getStatusColor(onlineStatus),
/> }}
</div> className="h-2.25 w-2.25 rounded-full"
} />
/> </div>
<TooltipContent> }
{user.OnlineStatus.split("_") />
.map((word) => word.charAt(0).toUpperCase() + word.slice(1)) <TooltipContent>
.join(" ")} {onlineStatus
</TooltipContent> .split("_")
</Tooltip> .map((word) => word.charAt(0).toUpperCase() + word.slice(1))
</div> .join(" ")}
<div className="flex flex-col gap-1 w-full items-start justify-center text-[15px]"> </TooltipContent>
<p>{user.Display}</p> </Tooltip>
</div> </div>
<div className="pr-1">{extra}</div> <div className="flex w-full flex-col items-start justify-center gap-1 text-[15px]">
</CardHeader> <p>{display}</p>
</Card> </div>
<div className="pr-1">{extra}</div>
</Button>
); );
} }

View file

@ -1,5 +1,5 @@
import type { User } from "@tensamin/user/context"; import type { User } from "@tensamin/user/context";
import { Avatar, AvatarFallback, AvatarImage, Button } from "@tensamin/ui"; import { Avatar, AvatarFallback, AvatarImage, Button } from "@methanium/ui";
import Text from "@tensamin/markdown/text"; import Text from "@tensamin/markdown/text";
import { ChevronDown, ChevronUp } from "lucide-react"; import { ChevronDown, ChevronUp } from "lucide-react";
import { useState } from "react"; import { useState } from "react";
@ -24,7 +24,7 @@ export default function Profile({ user }: { user: User }) {
<Text value={user.About || ""} /> <Text value={user.About || ""} />
<Button <Button
className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline" className="h-auto justify-start gap-1.5 px-0 py-1 text-base font-medium text-white no-underline hover:no-underline"
variant="link" variant="ghost"
aria-expanded={showAdvancedInformation} aria-expanded={showAdvancedInformation}
onClick={() => setShowAdvancedInformation((show) => !show)} onClick={() => setShowAdvancedInformation((show) => !show)}
> >

View file

@ -4,17 +4,29 @@ import {
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
useIsMobile, useIsMobile,
} from "@tensamin/ui"; } from "@methanium/ui";
import { ArrowLeft, House, Phone, Settings, User } from "lucide-react"; import {
ArrowLeft,
EllipsisVertical,
House,
Phone,
Settings,
User,
} from "lucide-react";
import { useLocation, useNavigate, useSearch } from "@tanstack/react-router"; import { useLocation, useNavigate, useSearch } from "@tanstack/react-router";
import { joinCall, useCall } from "@tensamin/call/store"; import { joinCall, useCall } from "@tensamin/call/store";
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/user/wrapper";
import { Skeleton } from "@tensamin/ui"; import { Skeleton } from "@methanium/ui";
import { Select, SelectContent, SelectItem, SelectTrigger } from "@tensamin/ui"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
} from "@methanium/ui";
import { displayCallId } from "@tensamin/call/utils"; import { displayCallId } from "@tensamin/call/utils";
import { useState } from "react"; import { useState } from "react";
import { SidebarTrigger, useSidebar } from "@tensamin/ui"; import { SidebarTrigger, useSidebar } from "@methanium/ui";
import { WindowControls as Controls } from "@tensamin/ui"; import { WindowControls as Controls } from "@methanium/ui";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import Profile from "./modals/profile"; import Profile from "./modals/profile";
@ -35,37 +47,47 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
const [userInfoOpen, setUserInfoOpen] = useState(false); const [userInfoOpen, setUserInfoOpen] = useState(false);
const { callId } = useCall();
return ( return (
<div <div
data-tauri-drag-region data-tauri-drag-region
className={`${forMobile && "border-b"} w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`} className={`${forMobile && "border-b"} pl-px w-full shrink-0 gap-2 h-13.5 flex items-center justify-between`}
> >
<div className="flex items-center justify-center gap-2"> <div className="flex items-center justify-center gap-2">
{forMobile ? ( {forMobile ? (
<SidebarTrigger <SidebarTrigger
className="w-9 h-9 aspect-square rounded-lg ml-2" render={({ onClick }) => (
variant="outline" <Button
> onClick={onClick}
<ArrowLeft className="size-4.5" /> className="w-9 h-9! ml-2"
</SidebarTrigger> variant="ghost"
>
<ArrowLeft className="size-4.5" />
</Button>
)}
/>
) : ( ) : (
<> <>
<Button <Button
onClick={() => navigate({ to: "/" })} onClick={() => navigate({ to: "/" })}
className="w-9 h-9 aspect-square rounded-lg" className="w-9 h-9! aspect-square rounded-lg"
variant="outline" variant="outline"
> >
<House className="size-4.5" /> <House className="size-4.5" />
</Button> </Button>
<Button <Button
onClick={() => navigate({ to: "/settings" })} onClick={() => navigate({ to: "/settings" })}
className="w-9 h-9 aspect-square rounded-lg" className="w-9 h-9! aspect-square rounded-lg"
variant="outline" variant="outline"
> >
<Settings className="size-4.5" /> <Settings className="size-4.5" />
</Button> </Button>
</> </>
)} )}
{isMobile && pathname === "/call" && callId && (
<p className="font-medium text-[1.07rem]">{displayCallId(callId)}</p>
)}
{pathname === "/chat" && id && ( {pathname === "/chat" && id && (
<Wrapper <Wrapper
userId={id} userId={id}
@ -75,10 +97,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
) : ( ) : (
<Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}> <Popover open={userInfoOpen} onOpenChange={setUserInfoOpen}>
<PopoverTrigger <PopoverTrigger
render={ render={({ onClick }) => (
<Button <Button
onClick={onClick}
variant="link" variant="link"
className="px-0! text-foreground" className="text-foreground px-0! border-0! bg-none! bg-transparent!"
style={{ style={{
textDecorationLine: "none", textDecorationLine: "none",
}} }}
@ -87,7 +110,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
{user?.Display} {user?.Display}
</p> </p>
</Button> </Button>
} )}
/> />
<PopoverContent side="bottom"> <PopoverContent side="bottom">
<Profile user={user} /> <Profile user={user} />
@ -109,7 +132,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
onClick={() => { onClick={() => {
void joinCall(id); void joinCall(id);
}} }}
className="w-9 h-9 aspect-square rounded-lg" className="w-9 h-9! aspect-square rounded-lg"
variant="outline" variant="outline"
> >
<Phone /> <Phone />
@ -124,7 +147,7 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
currentCalls[0].CallId, currentCalls[0].CallId,
); );
}} }}
className="w-9 h-9 aspect-square rounded-lg" className="w-9 h-9! aspect-square rounded-lg"
> >
<Phone /> <Phone />
</Button> </Button>
@ -161,6 +184,11 @@ export default function Navbar({ forMobile }: { forMobile: boolean }) {
)} )}
</> </>
)} )}
{isMobile && pathname === "/call" && callId && (
<Button variant="ghost">
<EllipsisVertical />
</Button>
)}
<Controls className="mr-2" /> <Controls className="mr-2" />
</div> </div>
</div> </div>
@ -175,7 +203,7 @@ export function MobileNavbar() {
<div className="z-51 w-full shrink-0 pb-[env(safe-area-inset-bottom)] bg-card border-t"> <div className="z-51 w-full shrink-0 pb-[env(safe-area-inset-bottom)] bg-card border-t">
<div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5"> <div className="z-51 w-full h-15 grid grid-cols-3 items-center place-items-center px-5">
<SidebarTrigger <SidebarTrigger
className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1" className="text-foreground w-12! h-12! aspect-square rounded-xl flex flex-col gap-1 border-0!"
variant="link" variant="link"
> >
<User className="size-4.5" /> <User className="size-4.5" />
@ -186,7 +214,7 @@ export function MobileNavbar() {
navigate({ to: "/" }); navigate({ to: "/" });
setOpenMobile(false); setOpenMobile(false);
}} }}
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1" className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!"
variant="link" variant="link"
> >
<House className="size-4.5" /> <House className="size-4.5" />
@ -197,7 +225,7 @@ export function MobileNavbar() {
navigate({ to: "/settings" }); navigate({ to: "/settings" });
setOpenMobile(false); setOpenMobile(false);
}} }}
className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1" className="text-foreground w-12 h-12 aspect-square rounded-xl flex flex-col gap-1 border-0! bg-none! bg-transparent!"
variant="link" variant="link"
> >
<Settings className="size-4.5" /> <Settings className="size-4.5" />

View file

@ -1,13 +1,21 @@
import { Button, cn, useIsMobile } from "@tensamin/ui"; import { Button, cn, useIsMobile } from "@methanium/ui";
import { Input } from "@tensamin/ui"; import { Input } from "@methanium/ui";
import { Label } from "@tensamin/ui"; import { Label } from "@methanium/ui";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { log, toast } from "@tensamin/shared/log"; import { log, toast } from "@tensamin/shared/log";
import { File } from "lucide-react"; import { File } from "lucide-react";
import * as React from "react"; import {
type ChangeEvent,
type FormEvent,
useCallback,
useEffect,
useRef,
useState,
} from "react";
import { z } from "zod"; import { z } from "zod";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import QrCodeScanner from "@tensamin/tauri/qrCodeScanner"; import QrCodeScanner from "@tensamin/tauri/qrCodeScanner";
import { useNavigate } from "@tanstack/react-router";
const fetchedUser = z.object({ const fetchedUser = z.object({
id: z.uuidv4(), id: z.uuidv4(),
@ -77,12 +85,13 @@ function parseTuFileContent(rawFileContent: string): {
export default function Form() { export default function Form() {
const isMobile = useIsMobile(); const isMobile = useIsMobile();
const uploadRef = React.useRef<HTMLInputElement | null>(null); const uploadRef = useRef<HTMLInputElement | null>(null);
const [isDragging, setIsDragging] = React.useState(false); const [isDragging, setIsDragging] = useState(false);
const { save } = useStorage(); const { save } = useStorage();
const loginPendingRef = React.useRef(false); const navigate = useNavigate();
const loginPendingRef = useRef(false);
const persistLogin = React.useCallback( const persistLogin = useCallback(
async (userId: number, privateKey: string, domain?: string | null) => { async (userId: number, privateKey: string, domain?: string | null) => {
if (loginPendingRef.current) return false; if (loginPendingRef.current) return false;
loginPendingRef.current = true; loginPendingRef.current = true;
@ -91,22 +100,17 @@ export default function Form() {
await save("mtp_keyring", privateKey, { secure: true }); await save("mtp_keyring", privateKey, { secure: true });
await save("session_id", Date.now()); await save("session_id", Date.now());
await save("user_id", userId); await save("user_id", userId);
window.history.replaceState( await navigate({ to: "/", replace: true });
null,
"",
window.location.protocol === "file:" ? "#/" : "/",
);
window.location.reload();
return true; return true;
} finally { } finally {
loginPendingRef.current = false; loginPendingRef.current = false;
} }
}, },
[save], [navigate, save],
); );
// Process dropped files // Process dropped files
const processDroppedFile = React.useCallback( const processDroppedFile = useCallback(
async (file: globalThis.File): Promise<void> => { async (file: globalThis.File): Promise<void> => {
try { try {
if (!file.name.endsWith(".tu")) { if (!file.name.endsWith(".tu")) {
@ -127,8 +131,8 @@ export default function Form() {
); );
// Handle .tu files // Handle .tu files
const handleFileInputChange = React.useCallback( const handleFileInputChange = useCallback(
async (event: React.ChangeEvent<HTMLInputElement>): Promise<void> => { async (event: ChangeEvent<HTMLInputElement>): Promise<void> => {
const file = event.currentTarget.files?.[0]; const file = event.currentTarget.files?.[0];
if (!file) { if (!file) {
@ -142,7 +146,7 @@ export default function Form() {
); );
// Drag and drop listener // Drag and drop listener
React.useEffect(() => { useEffect(() => {
let dragCounter = 0; let dragCounter = 0;
const handleDragEnter = (event: DragEvent) => { const handleDragEnter = (event: DragEvent) => {
@ -212,8 +216,8 @@ export default function Form() {
* @param event Form submit event. * @param event Form submit event.
* @returns Promise that resolves after login processing. * @returns Promise that resolves after login processing.
*/ */
const handleCredentialsSubmit = React.useCallback( const handleCredentialsSubmit = useCallback(
async (event: React.FormEvent<HTMLFormElement>): Promise<void> => { async (event: FormEvent<HTMLFormElement>): Promise<void> => {
event.preventDefault(); event.preventDefault();
const formData = new FormData(event.currentTarget); const formData = new FormData(event.currentTarget);

View file

@ -26,9 +26,9 @@ import {
SelectValue, SelectValue,
SelectContent, SelectContent,
SelectItem, SelectItem,
} from "@tensamin/ui"; } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
import { useIsMobile } from "@tensamin/ui"; import { useIsMobile } from "@methanium/ui";
import { MobileNavbar } from "./navbar"; import { MobileNavbar } from "./navbar";
import SidebarBox from "@tensamin/call/sidebarBox"; import SidebarBox from "@tensamin/call/sidebarBox";
@ -298,7 +298,7 @@ export default function Sidebar() {
data-sidebar="sidebar" data-sidebar="sidebar"
data-slot="sidebar" data-slot="sidebar"
data-mobile="true" data-mobile="true"
className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground transition-[transform,opacity] duration-150 ease-linear" className="fixed inset-y-0 left-0 z-50 w-screen bg-sidebar p-0 text-sidebar-foreground"
style={{ style={{
transform: openMobile ? "translateX(0)" : "translateX(-100%)", transform: openMobile ? "translateX(0)" : "translateX(-100%)",
opacity: openMobile ? 1 : 0, opacity: openMobile ? 1 : 0,

View file

@ -1,4 +1,4 @@
import * as React from "react"; import { useRef, useState } from "react";
import { useVirtualizer } from "@tanstack/react-virtual"; import { useVirtualizer } from "@tanstack/react-virtual";
import Switch from "./switch"; import Switch from "./switch";
@ -8,14 +8,14 @@ import { Loader2 } from "lucide-react";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
export default function List() { export default function List() {
const [category, setCategory] = React.useState< const [category, setCategory] = useState<"conversations" | "communities">(
"conversations" | "communities" "conversations",
>("conversations"); );
const { contacts, communities } = useSession(); const { contacts, communities } = useSession();
const items = category === "conversations" ? contacts : communities; const items = category === "conversations" ? contacts : communities;
const scrollRef = React.useRef<HTMLDivElement | null>(null); const scrollRef = useRef<HTMLDivElement | null>(null);
// TanStack Virtual is intentionally used here; React Compiler memoization is skipped. // TanStack Virtual is intentionally used here; React Compiler memoization is skipped.
// eslint-disable-next-line react-hooks/incompatible-library // eslint-disable-next-line react-hooks/incompatible-library
@ -31,7 +31,7 @@ export default function List() {
<div <div
ref={scrollRef} ref={scrollRef}
id="conversation-list" id="conversation-list"
className="overflow-y-auto flex-1 h-full" className="overflow-y-auto flex-1 h-full p-px"
> >
{items === null ? ( {items === null ? (
<div className="flex items-center justify-center pt-5"> <div className="flex items-center justify-center pt-5">

View file

@ -6,9 +6,9 @@ import {
ContextMenuGroup, ContextMenuGroup,
ContextMenuItem, ContextMenuItem,
ContextMenuTrigger, ContextMenuTrigger,
} from "@tensamin/ui"; } from "@methanium/ui";
import { useNavigate } from "@tanstack/react-router"; import { useNavigate } from "@tanstack/react-router";
import { useSidebar } from "@tensamin/ui"; import { useSidebar } from "@methanium/ui";
export default function ConversationModal({ userId }: { userId: number }) { export default function ConversationModal({ userId }: { userId: number }) {
const navigate = useNavigate(); const navigate = useNavigate();

View file

@ -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>
);
}

View file

@ -9,19 +9,19 @@ import {
} from "@tanstack/react-router"; } from "@tanstack/react-router";
import "./index.css"; import "./index.css";
import "@tensamin/ui/index.css"; import "@methanium/ui/index.css";
import NotFound from "@/routes/404"; import NotFound from "@/routes/404";
import AppLayout from "@/routes/app/layout"; import AppLayout from "@/routes/app/layout";
import { createSettingsRoute } from "@tensamin/settings"; import { createSettingsRoute } from "@tensamin/settings";
import OnboardingGate from "@tensamin/onboarding";
import Home from "@/routes/app/home"; import Home from "@/routes/app/home";
import ChatScreen from "@tensamin/chat/screen"; import ChatScreen from "@tensamin/chat/screen";
import CallScreen from "@tensamin/call/screen"; import CallScreen from "@tensamin/call/screen";
import Login from "@/routes/screens/login"; import Login from "@/routes/screens/login";
import CallPopout from "@tensamin/call/popout";
import ChatContext from "@tensamin/chat/context"; import ChatContext from "@tensamin/chat/context";
import { useCall, useInitializeCall } from "@tensamin/call/store"; import { useCall, useInitializeCall } from "@tensamin/call/store";
import { useIsSpeaking } from "@tensamin/call/speakingState"; import { useIsSpeaking } from "@tensamin/call/speakingState";
@ -32,7 +32,7 @@ import NotificationsProvider from "@tensamin/notifications/context";
import TAuthWrapper from "@tensamin/tauth/context"; import TAuthWrapper from "@tensamin/tauth/context";
import { ErrorScreen, ThemeProvider, useTheme } from "@tensamin/ui"; import { ErrorScreen, ThemeProvider, useTheme } from "@methanium/ui";
import z from "zod"; import z from "zod";
import { useEffect, useRef, useState, type ReactNode } from "react"; import { useEffect, useRef, useState, type ReactNode } from "react";
@ -43,12 +43,11 @@ import Crypto from "@tensamin/crypto/context";
import DesktopMediaProvider from "@tensamin/shared/desktopMedia"; import DesktopMediaProvider from "@tensamin/shared/desktopMedia";
import { log } from "@tensamin/shared/log"; import { log } from "@tensamin/shared/log";
import LegalWrapper from "@/features/legal/screen";
import CacheSync from "@tensamin/cache/sync"; import CacheSync from "@tensamin/cache/sync";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useLocation, useNavigate } from "@tanstack/react-router"; import { useLocation, useNavigate } from "@tanstack/react-router";
import { useIsMobile, Toaster, TooltipProvider } from "@tensamin/ui"; import { useIsMobile, Toaster, TooltipProvider } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
const wrapper = document.getElementById("root"); const wrapper = document.getElementById("root");
@ -131,6 +130,11 @@ function ThemeStorageBridge() {
setThemeBorderRadius, setThemeBorderRadius,
themeCustomCss, themeCustomCss,
setThemeCustomCss, setThemeCustomCss,
parentThemeId,
setParentThemeId,
applyThemePreset,
themeDesign,
setThemeDesign,
} = useTheme(); } = useTheme();
const loadedRef = useRef(false); const loadedRef = useRef(false);
@ -145,6 +149,8 @@ function ThemeStorageBridge() {
load("theme_tint"), load("theme_tint"),
load("theme_border_radius"), load("theme_border_radius"),
load("theme_custom_css"), load("theme_custom_css"),
load("theme_parent"),
load("theme_design"),
]).then( ]).then(
([ ([
color, color,
@ -154,18 +160,23 @@ function ThemeStorageBridge() {
tint, tint,
borderRadius, borderRadius,
customCss, customCss,
parent,
design,
]) => { ]) => {
if (!active) { if (!active) {
return; return;
} }
if (customCss === "" && parent) applyThemePreset(parent);
else setParentThemeId(parent || null);
setThemeColor(color); setThemeColor(color);
setThemePalette(palette); setThemePalette(palette);
setThemePrimaryColor(primaryColor); setThemePrimaryColor(primaryColor);
setThemePolarity(polarity); setThemePolarity(polarity);
setThemeTint(tint); setThemeTint(tint);
setThemeBorderRadius(borderRadius); setThemeBorderRadius(borderRadius);
setThemeCustomCss(customCss); if (customCss !== "") setThemeCustomCss(customCss);
setThemeDesign(design);
loadedRef.current = true; loadedRef.current = true;
}, },
); );
@ -175,9 +186,12 @@ function ThemeStorageBridge() {
}; };
}, [ }, [
load, load,
applyThemePreset,
setThemeBorderRadius, setThemeBorderRadius,
setThemeColor, setThemeColor,
setThemeCustomCss, setThemeCustomCss,
setParentThemeId,
setThemeDesign,
setThemePalette, setThemePalette,
setThemePolarity, setThemePolarity,
setThemePrimaryColor, setThemePrimaryColor,
@ -212,6 +226,14 @@ function ThemeStorageBridge() {
if (loadedRef.current) save("theme_custom_css", themeCustomCss); if (loadedRef.current) save("theme_custom_css", themeCustomCss);
}, [save, themeCustomCss]); }, [save, themeCustomCss]);
useEffect(() => {
if (loadedRef.current) save("theme_parent", parentThemeId ?? "");
}, [parentThemeId, save]);
useEffect(() => {
if (loadedRef.current) save("theme_design", themeDesign);
}, [save, themeDesign]);
return null; return null;
} }
@ -228,6 +250,8 @@ function RootShell() {
tintStorageKey={null} tintStorageKey={null}
borderRadiusStorageKey={null} borderRadiusStorageKey={null}
customCssStorageKey={null} customCssStorageKey={null}
parentThemeStorageKey={null}
designStorageKey={null}
> >
<div className="w-screen h-dvh overflow-hidden"> <div className="w-screen h-dvh overflow-hidden">
<Toaster <Toaster
@ -244,13 +268,7 @@ function RootShell() {
<Storage> <Storage>
<ThemeStorageBridge /> <ThemeStorageBridge />
<LoginWrapper> <LoginWrapper>
<LegalWrapper> <Outlet />
<Crypto>
<DesktopMediaProvider>
<Outlet />
</DesktopMediaProvider>
</Crypto>
</LegalWrapper>
</LoginWrapper> </LoginWrapper>
</Storage> </Storage>
</TooltipProvider> </TooltipProvider>
@ -262,24 +280,29 @@ function RootShell() {
function AppShell() { function AppShell() {
return ( return (
<MTPProvider> <OnboardingGate>
<CacheSync /> <Crypto>
<Session> <DesktopMediaProvider>
<UserProvider> <MTPProvider>
<CallInit /> <CacheSync />
<CallPopout /> <Session>
<TAuthWrapper> <UserProvider>
<AppLayout> <CallInit />
<ChatContext> <TAuthWrapper>
<NotificationsProvider> <AppLayout>
<Outlet /> <ChatContext>
</NotificationsProvider> <NotificationsProvider>
</ChatContext> <Outlet />
</AppLayout> </NotificationsProvider>
</TAuthWrapper> </ChatContext>
</UserProvider> </AppLayout>
</Session> </TAuthWrapper>
</MTPProvider> </UserProvider>
</Session>
</MTPProvider>
</DesktopMediaProvider>
</Crypto>
</OnboardingGate>
); );
} }
@ -307,7 +330,7 @@ function createCallTrayIcon(color: string, speaking: boolean) {
} }
function CallInit() { function CallInit() {
useInitializeCall(); const callInvitePopup = useInitializeCall();
const { load } = useStorage(); const { load } = useStorage();
const { const {
@ -370,7 +393,7 @@ function CallInit() {
}); });
}, [inCall, primaryColor, speaking]); }, [inCall, primaryColor, speaking]);
return null; return callInvitePopup;
} }
const rootRoute = createRootRoute({ const rootRoute = createRootRoute({

View file

@ -9,7 +9,7 @@ import {
Input, Input,
Button, Button,
useIsMobile, useIsMobile,
} from "@tensamin/ui"; } from "@methanium/ui";
import z from "zod"; import z from "zod";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { useState } from "react"; import { useState } from "react";
@ -79,7 +79,7 @@ function AddConversationButton() {
Username: result.data, Username: result.data,
}) })
.then((data) => { .then((data) => {
if (data.data.UserId === 0) { if (data.type === "ErrorNotFound" || data.data.UserId === 0) {
throw new Error(); throw new Error();
} }
@ -101,7 +101,7 @@ function AddConversationButton() {
const timeout = setTimeout(() => setLoading(true), 500); const timeout = setTimeout(() => setLoading(true), 500);
send("AddConversation", { send("AddConversation", {
ChatPartnerName: result.data, ChatPartnerId: user.data.UserId,
}) })
.then(() => { .then(() => {
insertContact(user.data.UserId); insertContact(user.data.UserId);
@ -131,8 +131,12 @@ function AddConversationButton() {
setOpen(value); setOpen(value);
}} }}
> >
<DialogTrigger render={<Button>Add Conversation</Button>} /> <DialogTrigger
<DialogContent> render={({ onClick }) => (
<Button onClick={onClick}>Add Conversation</Button>
)}
/>
<DialogContent showCloseButton={false}>
<DialogHeader> <DialogHeader>
<DialogTitle>New Conversation</DialogTitle> <DialogTitle>New Conversation</DialogTitle>
</DialogHeader> </DialogHeader>
@ -160,7 +164,13 @@ function AddConversationButton() {
{error && ( {error && (
<p className="text-sm text-destructive w-full">{error}</p> <p className="text-sm text-destructive w-full">{error}</p>
)} )}
<DialogClose render={<Button variant="outline">Cancel</Button>} /> <DialogClose
render={({ onClick }) => (
<Button onClick={onClick} variant="outline">
Cancel
</Button>
)}
/>
<Button disabled={loading} type="submit"> <Button disabled={loading} type="submit">
{loading && <Loader2 className="animate-spin" />} Continue {loading && <Loader2 className="animate-spin" />} Continue
</Button> </Button>

View file

@ -3,8 +3,9 @@ import { type ReactNode } from "react";
import Sidebar from "@/components/sidebar"; import Sidebar from "@/components/sidebar";
import Navbar, { MobileNavbar } from "@/components/navbar"; import Navbar, { MobileNavbar } from "@/components/navbar";
import { useShowMobileNavbar } from "./useShowMobileNavbar"; import { useShowMobileNavbar } from "./useShowMobileNavbar";
import CallPopout from "@tensamin/call/popout";
import { useIsMobile, cn, SidebarProvider } from "@tensamin/ui"; import { useIsMobile, cn, SidebarProvider } from "@methanium/ui";
import { isTauri } from "@tauri-apps/api/core"; import { isTauri } from "@tauri-apps/api/core";
@ -16,6 +17,7 @@ export default function Layout({ children }: { children: ReactNode }) {
<div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar"> <div className="w-full h-full min-h-0 flex overflow-hidden bg-sidebar">
<SidebarProvider className="h-full min-h-0 overflow-hidden"> <SidebarProvider className="h-full min-h-0 overflow-hidden">
<Sidebar /> <Sidebar />
<CallPopout />
<div <div
// Background of ui that is overlapping with the system ui // Background of ui that is overlapping with the system ui
className={cn( className={cn(

View file

@ -1,5 +1,5 @@
import Form from "@/components/screens/login/form"; import Form from "@/components/screens/login/form";
import { CreateScreen } from "@tensamin/ui"; import { CreateScreen } from "@methanium/ui";
/** /**
* Executes Page. * Executes Page.

View file

@ -7,6 +7,7 @@ import { defineConfig, type Plugin } from "vite";
import react from "@vitejs/plugin-react"; import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite"; import tailwindcss from "@tailwindcss/vite";
import { mtp } from "mtp/vite"; import { mtp } from "mtp/vite";
import { methaniumUi } from "@methanium/ui/vite";
const host = process.env.TAURI_DEV_HOST; const host = process.env.TAURI_DEV_HOST;
const appDir = dirname(fileURLToPath(import.meta.url)); const appDir = dirname(fileURLToPath(import.meta.url));
@ -105,7 +106,7 @@ export default defineConfig({
? { ? {
protocol: "ws", protocol: "ws",
host, host,
port: 1421, clientPort: 3000,
} }
: undefined, : undefined,
watch: { watch: {
@ -136,6 +137,7 @@ export default defineConfig({
"@tensamin/markdown", "@tensamin/markdown",
"@tensamin/mtp", "@tensamin/mtp",
"@tensamin/notifications", "@tensamin/notifications",
"@tensamin/onboarding",
"@tensamin/shared", "@tensamin/shared",
"@tensamin/shared/data", "@tensamin/shared/data",
"@tensamin/shared/log", "@tensamin/shared/log",
@ -152,8 +154,9 @@ export default defineConfig({
sourcemap: !!process.env.TAURI_ENV_DEBUG, sourcemap: !!process.env.TAURI_ENV_DEBUG,
}, },
plugins: [ plugins: [
methaniumUi({ defaultThemeId: "tensamin" }),
deepFilterAssetHeaders(resolve(appDir, "public")), deepFilterAssetHeaders(resolve(appDir, "public")),
mtp({ typeMaps: resolve(appDir, "../../type-maps.yaml") }), mtp({ typeMaps: resolve(appDir, "../../mtp-type-maps/type-maps.yaml") }),
{ {
name: "workspace-realpath-resolution", name: "workspace-realpath-resolution",
enforce: "post", enforce: "post",

View file

@ -5,6 +5,10 @@ import reactHooks from "eslint-plugin-react-hooks";
import * as tsParser from "@typescript-eslint/parser"; import * as tsParser from "@typescript-eslint/parser";
import { dirname } from "node:path"; import { dirname } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
import {
noReactNamespaceImport,
noWindowLocationReload,
} from "./utils/eslint-rules/index.js";
const tsconfigRootDir = dirname(fileURLToPath(import.meta.url)); const tsconfigRootDir = dirname(fileURLToPath(import.meta.url));
@ -28,10 +32,18 @@ export default [
}, },
plugins: { plugins: {
"react-hooks": reactHooks, "react-hooks": reactHooks,
tensamin: {
rules: {
"no-react-namespace-import": noReactNamespaceImport,
"no-window-location-reload": noWindowLocationReload,
},
},
}, },
rules: { rules: {
...reactHooks.configs.recommended.rules, ...reactHooks.configs.recommended.rules,
"react-hooks/set-state-in-effect": "off", "react-hooks/set-state-in-effect": "off",
"tensamin/no-react-namespace-import": "error",
"tensamin/no-window-location-reload": "error",
}, },
}, },
]; ];

View file

@ -53,6 +53,7 @@
libglvnd libglvnd
libnotify libnotify
libpulseaudio libpulseaudio
libsecret
libuuid libuuid
libxkbcommon libxkbcommon
mesa mesa
@ -104,7 +105,8 @@
cp -r usr/* "$out/" cp -r usr/* "$out/"
mkdir -p "$out/bin" mkdir -p "$out/bin"
makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin" makeWrapper "$out/opt/Tensamin/tensamin" "$out/bin/tensamin" \
--prefix LD_LIBRARY_PATH : "${pkgs.lib.makeLibraryPath electronRuntimeLibs}"
substituteInPlace "$out/share/applications/Tensamin.desktop" \ substituteInPlace "$out/share/applications/Tensamin.desktop" \
--replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin" --replace-fail "Exec=/opt/Tensamin/tensamin" "Exec=tensamin"
@ -172,6 +174,7 @@
libglvnd libglvnd
libnotify libnotify
libpulseaudio libpulseaudio
libsecret
libuuid libuuid
libxkbcommon libxkbcommon
mesa mesa

View file

@ -1,9 +1,9 @@
The MIT License (MIT) MIT License
Copyright (c) 2022 Paul Miller (https://paulmillr.com) Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
@ -12,10 +12,10 @@ furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software. all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.

View file

@ -1,9 +1,9 @@
The MIT License (MIT) MIT License
Copyright (c) 2022 Paul Miller (https://paulmillr.com) Copyright (C) 2018-2021 by Marijn Haverbeke <marijn@haverbeke.berlin> and others
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the “Software”), to deal of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is copies of the Software, and to permit persons to whom the Software is
@ -12,10 +12,10 @@ furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software. all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 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 OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE. THE SOFTWARE.

View file

@ -0,0 +1,22 @@
MIT License
Copyright (c) 2022present Jason Sofonia & Justine De Caires
Copyright (c) 20142021 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.

View file

@ -31,6 +31,15 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/@base-ui_utils@0.3.1` - Folder: `licenses/@base-ui_utils@0.3.1`
- Source package dir: `apps/web/node_modules/@base-ui/utils` - Source package dir: `apps/web/node_modules/@base-ui/utils`
## @codemirror/autocomplete@6.20.3
- License: MIT
- Repository: git+https://code.haverbeke.berlin/codemirror/autocomplete.git
- Description: Autocompletion for the CodeMirror code editor
- Included files: LICENSE
- Folder: `licenses/@codemirror_autocomplete@6.20.3`
- Source package dir: `packages/markdown/node_modules/@codemirror/autocomplete`
## @codemirror/commands@6.10.4 ## @codemirror/commands@6.10.4
- License: MIT - License: MIT
@ -49,6 +58,15 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/@codemirror_lang-markdown@6.5.0` - Folder: `licenses/@codemirror_lang-markdown@6.5.0`
- Source package dir: `packages/markdown/node_modules/@codemirror/lang-markdown` - Source package dir: `packages/markdown/node_modules/@codemirror/lang-markdown`
## @codemirror/language@6.12.4
- License: MIT
- Repository: git+https://code.haverbeke.berlin/codemirror/language.git
- Description: Language support infrastructure for the CodeMirror code editor
- Included files: LICENSE
- Folder: `licenses/@codemirror_language@6.12.4`
- Source package dir: `packages/markdown/node_modules/@codemirror/language`
## @codemirror/state@6.7.0 ## @codemirror/state@6.7.0
- License: MIT - License: MIT
@ -145,26 +163,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/@livekit_components-react@2.9.21` - Folder: `licenses/@livekit_components-react@2.9.21`
- Source package dir: `packages/call/node_modules/@livekit/components-react` - Source package dir: `packages/call/node_modules/@livekit/components-react`
## @noble/curves@2.2.0
- License: MIT
- Homepage: https://paulmillr.com/noble/
- Repository: git+https://github.com/paulmillr/noble-curves.git
- Description: Audited & minimal JS implementation of elliptic curve cryptography
- Included files: LICENSE
- Folder: `licenses/@noble_curves@2.2.0`
- Source package dir: `packages/crypto/node_modules/@noble/curves`
## @noble/hashes@2.2.0
- License: MIT
- Homepage: https://paulmillr.com/noble/
- Repository: git+https://github.com/paulmillr/noble-hashes.git
- Description: Audited & minimal 0-dependency JS implementation of SHA, RIPEMD, BLAKE, HMAC, HKDF, PBKDF & Scrypt
- Included files: LICENSE
- Folder: `licenses/@noble_hashes@2.2.0`
- Source package dir: `packages/crypto/node_modules/@noble/hashes`
## @radix-ui/primitive@1.1.4 ## @radix-ui/primitive@1.1.4
- License: MIT - License: MIT
@ -494,13 +492,23 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/@tauri-apps_plugin-notification@2.3.3` - Folder: `licenses/@tauri-apps_plugin-notification@2.3.3`
- Source package dir: `apps/tauri/node_modules/@tauri-apps/plugin-notification` - Source package dir: `apps/tauri/node_modules/@tauri-apps/plugin-notification`
## @tensamin/ui@0.0.39 ## @tensamin/ui@0.0.41
- License: UNKNOWN - License: UNKNOWN
- Included files: none found - Included files: none found
- Folder: `licenses/@tensamin_ui@0.0.39` - Folder: `licenses/@tensamin_ui@0.0.41`
- Source package dir: `apps/tauri/node_modules/@tensamin/ui` - Source package dir: `apps/tauri/node_modules/@tensamin/ui`
## @twemoji/api@17.0.3
- License: MIT AND CC-BY-4.0
- Homepage: https://github.com/jdecked/twemoji
- Repository: git://github.com/jdecked/twemoji.git
- Description: A Unicode standard based way to implement emoji across all platforms.
- Included files: LICENSE
- Folder: `licenses/@twemoji_api@17.0.3`
- Source package dir: `packages/markdown/node_modules/@twemoji/api`
## @types/node@25.9.4 ## @types/node@25.9.4
- License: MIT - License: MIT
@ -599,15 +607,6 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/cmdk@1.1.1` - Folder: `licenses/cmdk@1.1.1`
- Source package dir: `apps/web/node_modules/cmdk` - Source package dir: `apps/web/node_modules/cmdk`
## comlink@4.4.2
- License: Apache-2.0
- Repository: https://github.com/GoogleChromeLabs/comlink.git
- Description: Comlink makes WebWorkers enjoyable
- Included files: LICENSE
- Folder: `licenses/comlink@4.4.2`
- Source package dir: `packages/crypto/node_modules/comlink`
## cookie-es@3.1.1 ## cookie-es@3.1.1
- License: MIT - License: MIT
@ -823,6 +822,15 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/embla-carousel-reactive-utils@8.6.0` - Folder: `licenses/embla-carousel-reactive-utils@8.6.0`
- Source package dir: `apps/web/node_modules/embla-carousel-reactive-utils` - Source package dir: `apps/web/node_modules/embla-carousel-reactive-utils`
## emojibase-data@17.0.0
- License: MIT
- Repository: git@github.com:milesj/emojibase.git
- Description: Evergreen emoji datasets.
- Included files: LICENSE
- Folder: `licenses/emojibase-data@17.0.0`
- Source package dir: `packages/markdown/node_modules/emojibase-data`
## es-toolkit@1.49.0 ## es-toolkit@1.49.0
- License: MIT - License: MIT
@ -958,12 +966,21 @@ Generated from pnpm-lock.yaml and installed packages in workspace node_modules f
- Folder: `licenses/lucide-react@1.23.0` - Folder: `licenses/lucide-react@1.23.0`
- Source package dir: `apps/web/node_modules/lucide-react` - Source package dir: `apps/web/node_modules/lucide-react`
## mtp@0.1.0 ## motion@12.42.2
- License: MIT
- Repository: https://github.com/motiondivision/motion
- Description: An animation library for JavaScript and React.
- Included files: LICENSE.md
- Folder: `licenses/motion@12.42.2`
- Source package dir: `packages/chat/node_modules/motion`
## mtp@0.2.0
- License: UNKNOWN - License: UNKNOWN
- Description: MTP TypeScript SDK - Description: MTP TypeScript SDK
- Included files: none found - Included files: none found
- Folder: `licenses/mtp@0.1.0` - Folder: `licenses/mtp@0.2.0`
- Source package dir: `packages/mtp/node_modules/mtp` - Source package dir: `packages/mtp/node_modules/mtp`
## next-themes@0.4.6 ## next-themes@0.4.6

View file

@ -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.

View 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.

View 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.

View file

@ -3,7 +3,7 @@
"specVersion": "1.5", "specVersion": "1.5",
"version": 1, "version": 1,
"metadata": { "metadata": {
"timestamp": "2026-07-03T19:50:00.640Z", "timestamp": "2026-07-24T10:43:11.914Z",
"tools": [ "tools": [
{ {
"vendor": "OpenAI", "vendor": "OpenAI",
@ -117,6 +117,37 @@
} }
] ]
}, },
{
"type": "library",
"bomRef": "pkg:npm/%40codemirror/autocomplete@6.20.3",
"name": "@codemirror/autocomplete",
"version": "6.20.3",
"purl": "pkg:npm/%40codemirror/autocomplete@6.20.3",
"description": "Autocompletion for the CodeMirror code editor",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "git+https://code.haverbeke.berlin/codemirror/autocomplete.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@codemirror_autocomplete@6.20.3"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/@codemirror/autocomplete"
}
]
},
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/%40codemirror/commands@6.10.4", "bomRef": "pkg:npm/%40codemirror/commands@6.10.4",
@ -179,6 +210,37 @@
} }
] ]
}, },
{
"type": "library",
"bomRef": "pkg:npm/%40codemirror/language@6.12.4",
"name": "@codemirror/language",
"version": "6.12.4",
"purl": "pkg:npm/%40codemirror/language@6.12.4",
"description": "Language support infrastructure for the CodeMirror code editor",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "git+https://code.haverbeke.berlin/codemirror/language.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@codemirror_language@6.12.4"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/@codemirror/language"
}
]
},
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/%40codemirror/state@6.7.0", "bomRef": "pkg:npm/%40codemirror/state@6.7.0",
@ -516,76 +578,6 @@
} }
] ]
}, },
{
"type": "library",
"bomRef": "pkg:npm/%40noble/curves@2.2.0",
"name": "@noble/curves",
"version": "2.2.0",
"purl": "pkg:npm/%40noble/curves@2.2.0",
"description": "Audited & minimal JS implementation of elliptic curve cryptography",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://paulmillr.com/noble/"
},
{
"type": "vcs",
"url": "git+https://github.com/paulmillr/noble-curves.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@noble_curves@2.2.0"
},
{
"name": "local:sourcePackageDir",
"value": "packages/crypto/node_modules/@noble/curves"
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/%40noble/hashes@2.2.0",
"name": "@noble/hashes",
"version": "2.2.0",
"purl": "pkg:npm/%40noble/hashes@2.2.0",
"description": "Audited & minimal 0-dependency JS implementation of SHA, RIPEMD, BLAKE, HMAC, HKDF, PBKDF & Scrypt",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://paulmillr.com/noble/"
},
{
"type": "vcs",
"url": "git+https://github.com/paulmillr/noble-hashes.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@noble_hashes@2.2.0"
},
{
"name": "local:sourcePackageDir",
"value": "packages/crypto/node_modules/@noble/hashes"
}
]
},
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/%40radix-ui/primitive@1.1.4", "bomRef": "pkg:npm/%40radix-ui/primitive@1.1.4",
@ -1780,15 +1772,15 @@
}, },
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/%40tensamin/ui@0.0.39", "bomRef": "pkg:npm/%40tensamin/ui@0.0.41",
"name": "@tensamin/ui", "name": "@tensamin/ui",
"version": "0.0.39", "version": "0.0.41",
"purl": "pkg:npm/%40tensamin/ui@0.0.39", "purl": "pkg:npm/%40tensamin/ui@0.0.41",
"externalReferences": [], "externalReferences": [],
"properties": [ "properties": [
{ {
"name": "local:licenseFolder", "name": "local:licenseFolder",
"value": "licenses/@tensamin_ui@0.0.39" "value": "licenses/@tensamin_ui@0.0.41"
}, },
{ {
"name": "local:sourcePackageDir", "name": "local:sourcePackageDir",
@ -1796,6 +1788,41 @@
} }
] ]
}, },
{
"type": "library",
"bomRef": "pkg:npm/%40twemoji/api@17.0.3",
"name": "@twemoji/api",
"version": "17.0.3",
"purl": "pkg:npm/%40twemoji/api@17.0.3",
"description": "A Unicode standard based way to implement emoji across all platforms.",
"licenses": [
{
"license": {
"name": "MIT AND CC-BY-4.0"
}
}
],
"externalReferences": [
{
"type": "website",
"url": "https://github.com/jdecked/twemoji"
},
{
"type": "vcs",
"url": "git://github.com/jdecked/twemoji.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/@twemoji_api@17.0.3"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/@twemoji/api"
}
]
},
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/%40types/node@25.9.4", "bomRef": "pkg:npm/%40types/node@25.9.4",
@ -2141,37 +2168,6 @@
} }
] ]
}, },
{
"type": "library",
"bomRef": "pkg:npm/comlink@4.4.2",
"name": "comlink",
"version": "4.4.2",
"purl": "pkg:npm/comlink@4.4.2",
"description": "Comlink makes WebWorkers enjoyable",
"licenses": [
{
"license": {
"id": "Apache-2.0"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "https://github.com/GoogleChromeLabs/comlink.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/comlink@4.4.2"
},
{
"name": "local:sourcePackageDir",
"value": "packages/crypto/node_modules/comlink"
}
]
},
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/cookie-es@3.1.1", "bomRef": "pkg:npm/cookie-es@3.1.1",
@ -2925,6 +2921,37 @@
} }
] ]
}, },
{
"type": "library",
"bomRef": "pkg:npm/emojibase-data@17.0.0",
"name": "emojibase-data",
"version": "17.0.0",
"purl": "pkg:npm/emojibase-data@17.0.0",
"description": "Evergreen emoji datasets.",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "git@github.com:milesj/emojibase.git"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/emojibase-data@17.0.0"
},
{
"name": "local:sourcePackageDir",
"value": "packages/markdown/node_modules/emojibase-data"
}
]
},
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/es-toolkit@1.49.0", "bomRef": "pkg:npm/es-toolkit@1.49.0",
@ -3397,16 +3424,47 @@
}, },
{ {
"type": "library", "type": "library",
"bomRef": "pkg:npm/mtp@0.1.0", "bomRef": "pkg:npm/motion@12.42.2",
"name": "motion",
"version": "12.42.2",
"purl": "pkg:npm/motion@12.42.2",
"description": "An animation library for JavaScript and React.",
"licenses": [
{
"license": {
"id": "MIT"
}
}
],
"externalReferences": [
{
"type": "vcs",
"url": "https://github.com/motiondivision/motion"
}
],
"properties": [
{
"name": "local:licenseFolder",
"value": "licenses/motion@12.42.2"
},
{
"name": "local:sourcePackageDir",
"value": "packages/chat/node_modules/motion"
}
]
},
{
"type": "library",
"bomRef": "pkg:npm/mtp@0.2.0",
"name": "mtp", "name": "mtp",
"version": "0.1.0", "version": "0.2.0",
"purl": "pkg:npm/mtp@0.1.0", "purl": "pkg:npm/mtp@0.2.0",
"description": "MTP TypeScript SDK", "description": "MTP TypeScript SDK",
"externalReferences": [], "externalReferences": [],
"properties": [ "properties": [
{ {
"name": "local:licenseFolder", "name": "local:licenseFolder",
"value": "licenses/mtp@0.1.0" "value": "licenses/mtp@0.2.0"
}, },
{ {
"name": "local:sourcePackageDir", "name": "local:sourcePackageDir",
@ -4851,4 +4909,4 @@
] ]
} }
] ]
} }

View file

@ -1,6 +1,6 @@
{ {
"generatedAt": "2026-07-03T19:50:00.637Z", "generatedAt": "2026-07-24T10:43:11.913Z",
"packageCount": 143, "packageCount": 145,
"packages": [ "packages": [
{ {
"name": "@babel/runtime", "name": "@babel/runtime",
@ -41,6 +41,19 @@
"licenseFolder": "licenses/@base-ui_utils@0.3.1", "licenseFolder": "licenses/@base-ui_utils@0.3.1",
"sourcePackageDir": "apps/web/node_modules/@base-ui/utils" "sourcePackageDir": "apps/web/node_modules/@base-ui/utils"
}, },
{
"name": "@codemirror/autocomplete",
"version": "6.20.3",
"license": "MIT",
"homepage": null,
"repository": "git+https://code.haverbeke.berlin/codemirror/autocomplete.git",
"description": "Autocompletion for the CodeMirror code editor",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@codemirror_autocomplete@6.20.3",
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/autocomplete"
},
{ {
"name": "@codemirror/commands", "name": "@codemirror/commands",
"version": "6.10.4", "version": "6.10.4",
@ -67,6 +80,19 @@
"licenseFolder": "licenses/@codemirror_lang-markdown@6.5.0", "licenseFolder": "licenses/@codemirror_lang-markdown@6.5.0",
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/lang-markdown" "sourcePackageDir": "packages/markdown/node_modules/@codemirror/lang-markdown"
}, },
{
"name": "@codemirror/language",
"version": "6.12.4",
"license": "MIT",
"homepage": null,
"repository": "git+https://code.haverbeke.berlin/codemirror/language.git",
"description": "Language support infrastructure for the CodeMirror code editor",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@codemirror_language@6.12.4",
"sourcePackageDir": "packages/markdown/node_modules/@codemirror/language"
},
{ {
"name": "@codemirror/state", "name": "@codemirror/state",
"version": "6.7.0", "version": "6.7.0",
@ -197,32 +223,6 @@
"licenseFolder": "licenses/@livekit_components-react@2.9.21", "licenseFolder": "licenses/@livekit_components-react@2.9.21",
"sourcePackageDir": "packages/call/node_modules/@livekit/components-react" "sourcePackageDir": "packages/call/node_modules/@livekit/components-react"
}, },
{
"name": "@noble/curves",
"version": "2.2.0",
"license": "MIT",
"homepage": "https://paulmillr.com/noble/",
"repository": "git+https://github.com/paulmillr/noble-curves.git",
"description": "Audited & minimal JS implementation of elliptic curve cryptography",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@noble_curves@2.2.0",
"sourcePackageDir": "packages/crypto/node_modules/@noble/curves"
},
{
"name": "@noble/hashes",
"version": "2.2.0",
"license": "MIT",
"homepage": "https://paulmillr.com/noble/",
"repository": "git+https://github.com/paulmillr/noble-hashes.git",
"description": "Audited & minimal 0-dependency JS implementation of SHA, RIPEMD, BLAKE, HMAC, HKDF, PBKDF & Scrypt",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@noble_hashes@2.2.0",
"sourcePackageDir": "packages/crypto/node_modules/@noble/hashes"
},
{ {
"name": "@radix-ui/primitive", "name": "@radix-ui/primitive",
"version": "1.1.4", "version": "1.1.4",
@ -368,7 +368,7 @@
}, },
{ {
"name": "@radix-ui/react-slot", "name": "@radix-ui/react-slot",
"version": "1.2.1", "version": "1.3.0",
"license": "MIT", "license": "MIT",
"homepage": "https://radix-ui.com/primitives", "homepage": "https://radix-ui.com/primitives",
"repository": "git+https://github.com/radix-ui/primitives.git", "repository": "git+https://github.com/radix-ui/primitives.git",
@ -682,15 +682,28 @@
}, },
{ {
"name": "@tensamin/ui", "name": "@tensamin/ui",
"version": "0.0.39", "version": "0.0.41",
"license": "UNKNOWN", "license": "UNKNOWN",
"homepage": null, "homepage": null,
"repository": null, "repository": null,
"description": null, "description": null,
"files": [], "files": [],
"licenseFolder": "licenses/@tensamin_ui@0.0.39", "licenseFolder": "licenses/@tensamin_ui@0.0.41",
"sourcePackageDir": "apps/tauri/node_modules/@tensamin/ui" "sourcePackageDir": "apps/tauri/node_modules/@tensamin/ui"
}, },
{
"name": "@twemoji/api",
"version": "17.0.3",
"license": "MIT AND CC-BY-4.0",
"homepage": "https://github.com/jdecked/twemoji",
"repository": "git://github.com/jdecked/twemoji.git",
"description": "A Unicode standard based way to implement emoji across all platforms.",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/@twemoji_api@17.0.3",
"sourcePackageDir": "packages/markdown/node_modules/@twemoji/api"
},
{ {
"name": "@types/node", "name": "@types/node",
"version": "25.9.4", "version": "25.9.4",
@ -821,19 +834,6 @@
"licenseFolder": "licenses/cmdk@1.1.1", "licenseFolder": "licenses/cmdk@1.1.1",
"sourcePackageDir": "apps/web/node_modules/cmdk" "sourcePackageDir": "apps/web/node_modules/cmdk"
}, },
{
"name": "comlink",
"version": "4.4.2",
"license": "Apache-2.0",
"homepage": null,
"repository": "https://github.com/GoogleChromeLabs/comlink.git",
"description": "Comlink makes WebWorkers enjoyable",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/comlink@4.4.2",
"sourcePackageDir": "packages/crypto/node_modules/comlink"
},
{ {
"name": "cookie-es", "name": "cookie-es",
"version": "3.1.1", "version": "3.1.1",
@ -1018,7 +1018,7 @@
}, },
{ {
"name": "deepfilternet3-noise-filter", "name": "deepfilternet3-noise-filter",
"version": "1.3.0", "version": "1.2.1",
"license": "(Apache-2.0 OR MIT)", "license": "(Apache-2.0 OR MIT)",
"homepage": "https://github.com/mezonai/mezon-noise-suppression#readme", "homepage": "https://github.com/mezonai/mezon-noise-suppression#readme",
"repository": "git+https://github.com/mezonai/mezon-noise-suppression.git", "repository": "git+https://github.com/mezonai/mezon-noise-suppression.git",
@ -1115,6 +1115,19 @@
"licenseFolder": "licenses/embla-carousel-reactive-utils@8.6.0", "licenseFolder": "licenses/embla-carousel-reactive-utils@8.6.0",
"sourcePackageDir": "apps/web/node_modules/embla-carousel-reactive-utils" "sourcePackageDir": "apps/web/node_modules/embla-carousel-reactive-utils"
}, },
{
"name": "emojibase-data",
"version": "17.0.0",
"license": "MIT",
"homepage": null,
"repository": "git@github.com:milesj/emojibase.git",
"description": "Evergreen emoji datasets.",
"files": [
"LICENSE"
],
"licenseFolder": "licenses/emojibase-data@17.0.0",
"sourcePackageDir": "packages/markdown/node_modules/emojibase-data"
},
{ {
"name": "es-toolkit", "name": "es-toolkit",
"version": "1.49.0", "version": "1.49.0",
@ -1293,15 +1306,28 @@
"licenseFolder": "licenses/lucide-react@1.23.0", "licenseFolder": "licenses/lucide-react@1.23.0",
"sourcePackageDir": "apps/web/node_modules/lucide-react" "sourcePackageDir": "apps/web/node_modules/lucide-react"
}, },
{
"name": "motion",
"version": "12.42.2",
"license": "MIT",
"homepage": null,
"repository": "https://github.com/motiondivision/motion",
"description": "An animation library for JavaScript and React.",
"files": [
"LICENSE.md"
],
"licenseFolder": "licenses/motion@12.42.2",
"sourcePackageDir": "packages/chat/node_modules/motion"
},
{ {
"name": "mtp", "name": "mtp",
"version": "0.1.0", "version": "0.2.0",
"license": "UNKNOWN", "license": "UNKNOWN",
"homepage": null, "homepage": null,
"repository": null, "repository": null,
"description": "MTP TypeScript SDK", "description": "MTP TypeScript SDK",
"files": [], "files": [],
"licenseFolder": "licenses/mtp@0.1.0", "licenseFolder": "licenses/mtp@0.2.0",
"sourcePackageDir": "packages/mtp/node_modules/mtp" "sourcePackageDir": "packages/mtp/node_modules/mtp"
}, },
{ {
@ -1847,4 +1873,4 @@
"sourcePackageDir": "packages/call/node_modules/zustand" "sourcePackageDir": "packages/call/node_modules/zustand"
} }
] ]
} }

1
mtp-type-maps Submodule

@ -0,0 +1 @@
Subproject commit 11a1d79409857b734e948dd8c3e28e6ba721d15f

View file

@ -10,15 +10,15 @@
"type": "module", "type": "module",
"scripts": { "scripts": {
"format": "pnpm exec prettier --write .", "format": "pnpm exec prettier --write .",
"lint": "node scripts/lint-packages.ts", "lint": "node utils/scripts/lint-packages.ts",
"check": "fallow && pnpm run lint", "check": "fallow && pnpm run lint",
"copy-licenses": "node scripts/copy-licenses.ts", "copy-licenses": "node utils/scripts/copy-licenses.ts",
"copy-releases": "node scripts/copy-releases.ts", "copy-releases": "node utils/scripts/copy-releases.ts",
"pre-build": "pnpm run lint && pnpm run build:packages", "pre-build": "pnpm run lint && pnpm run build:packages",
"build:apps": "pnpm run copy-licenses && pnpm run pre-build && pnpm run build:mobile && pnpm run build:desktop && pnpm run copy-releases", "build:apps": "pnpm run copy-licenses && pnpm run pre-build && pnpm run build:mobile && pnpm run build:desktop && pnpm run copy-releases",
"build:packages": "node scripts/build-packages.ts", "build:packages": "node utils/scripts/build-packages.ts",
"update:packages": "node scripts/update-packages.ts", "update:packages": "node utils/scripts/update-packages.ts",
"dev:web": "cd apps/web && pnpm dev", "dev": "cd apps/web && pnpm dev",
"build:web": "cd apps/web && pnpm run build", "build:web": "cd apps/web && pnpm run build",
"preview:web": "cd apps/web && pnpm run preview", "preview:web": "cd apps/web && pnpm run preview",
"dev:mobile": "cd apps/tauri && pnpm dev:mobile", "dev:mobile": "cd apps/tauri && pnpm dev:mobile",
@ -26,7 +26,7 @@
"start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile", "start-adb:mobile": "cd apps/tauri && pnpm run start-adb:mobile",
"dev:desktop": "cd apps/electron && pnpm run dev", "dev:desktop": "cd apps/electron && pnpm run dev",
"build:desktop": "cd apps/electron && pnpm run package", "build:desktop": "cd apps/electron && pnpm run package",
"delete:mobile": "cd apps/tauri && nix develop ../..#tauri --command adb uninstall net.tensamin.client" "delete:mobile": "cd apps/tauri && nix develop ../..#tauri --command adb uninstall net.tensamin.client.dev"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
@ -46,7 +46,7 @@
"yaml": "^2.8.2" "yaml": "^2.8.2"
}, },
"dependencies": { "dependencies": {
"@tensamin/ui": "*", "@methanium/ui": "https://git.methanium.net/methanium/ui/releases/download/0.0.2/methanium-ui.tgz",
"mtp": "*", "mtp": "*",
"sonner": "^2.0.7" "sonner": "^2.0.7"
} }

View file

@ -175,12 +175,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
const userId = Number(key); const userId = Number(key);
return retained.has(userId) return retained.has(userId)
? [] ? []
: [ : [deleteDatabaseEntry("cache", storedKey("conversations", key))];
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
];
}), }),
); );
}, },
@ -210,12 +205,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
const userId = Number(key); const userId = Number(key);
return retained.has(userId) return retained.has(userId)
? [] ? []
: [ : [deleteDatabaseEntry("cache", storedKey("conversations", key))];
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
];
}), }),
); );
}, },
@ -241,12 +231,7 @@ export function createCache(accountId: string, options: CacheOptions = {}) {
storedEntries.flatMap(([key]) => storedEntries.flatMap(([key]) =>
retained.has(Number(key)) retained.has(Number(key))
? [] ? []
: [ : [deleteDatabaseEntry("cache", storedKey("conversations", key))],
deleteDatabaseEntry(
"cache",
storedKey("conversations", key),
),
],
), ),
); );
}, },

View file

@ -18,7 +18,6 @@ export default function CacheSync() {
const { load } = useStorage(); const { load } = useStorage();
const [accountId, setAccountId] = useState(0); const [accountId, setAccountId] = useState(0);
const queueRef = useRef(Promise.resolve()); const queueRef = useRef(Promise.resolve());
const reactionPartnersRef = useRef(new Map<number, number>());
useEffect(() => { useEffect(() => {
void load("user_id").then(setAccountId); void load("user_id").then(setAccountId);
@ -143,6 +142,7 @@ export default function CacheSync() {
MessageState: "sent", MessageState: "sent",
SenderId: accountId, SenderId: accountId,
SendTime: Number(request.SendTime), SendTime: Number(request.SendTime),
ReplyId: request.ReplyId ? Number(request.ReplyId) : undefined,
}); });
return; return;
} }
@ -195,23 +195,6 @@ export default function CacheSync() {
); );
return; return;
} }
if (type === "MessageGet") {
const message = result as unknown as CachedMessage;
const mappedPartner = reactionPartnersRef.current.get(message.SendTime);
reactionPartnersRef.current.delete(message.SendTime);
if (mappedPartner) {
await insertMessage(mappedPartner, message);
return;
}
const windows = await secureCache().conversations.list();
const window = windows.find((candidate) =>
candidate.Messages.some(
(cached) => cached.SendTime === message.SendTime,
),
);
if (window) await insertMessage(window.UserId, message);
}
}, },
[accountId, insertMessage, removeMessage, replaceMessage, secureCache], [accountId, insertMessage, removeMessage, replaceMessage, secureCache],
); );
@ -259,13 +242,28 @@ export default function CacheSync() {
return; return;
} }
if (message.type === "MessageReactionLive") { if (message.type === "MessageReactionLive") {
reactionPartnersRef.current.set( const partnerId = Number(data.ChatPartnerId);
Number(data.SendTime), const sendTime = Number(data.SendTime);
Number(data.ChatPartnerId), const senderId = Number(data.SenderId);
const reaction = String(data.Reaction);
const cache = secureCache();
const window = await cache.conversations.get(partnerId);
const target = window?.Messages.find(
(candidate) => candidate.SendTime === sendTime,
); );
if (!window || !target) return;
const reactions = (target.Reactions ?? []).filter(
(candidate) =>
candidate.SenderId !== senderId || candidate.Reaction !== reaction,
);
if (data.Accepted === true) {
reactions.push({ SenderId: senderId, Reaction: reaction });
}
await replaceMessage(partnerId, sendTime, { Reactions: reactions });
} }
}, },
[accountId, insertMessage, removeMessage, replaceMessage], [accountId, insertMessage, removeMessage, replaceMessage, secureCache],
); );
useEffect(() => { useEffect(() => {

View file

@ -19,12 +19,11 @@
"dependencies": { "dependencies": {
"@livekit/components-react": "^2.9.20", "@livekit/components-react": "^2.9.20",
"@tanstack/react-router": "^1.169.1", "@tanstack/react-router": "^1.169.1",
"@tauri-apps/api": "^2",
"@tensamin/crypto": "workspace:*", "@tensamin/crypto": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*", "@tensamin/storage": "workspace:*",
"@tensamin/mtp": "workspace:*", "@tensamin/mtp": "workspace:*",
"@tensamin/ui": "*", "@methanium/ui": "*",
"@tensamin/user": "workspace:*", "@tensamin/user": "workspace:*",
"deepfilternet3-noise-filter": "1.2.1", "deepfilternet3-noise-filter": "1.2.1",
"livekit-client": "^2.18.8", "livekit-client": "^2.18.8",

View file

@ -5,11 +5,13 @@ import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@tensamin/ui"; useIsMobile,
cn,
} from "@methanium/ui";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import MuteButton from "./buttons/mute"; import MuteButton from "./buttons/mute";
import DeafButton from "./buttons/deaf"; import DeafButton from "./buttons/deaf";
import ScreenshareButton from "./buttons/screenshare"; import MediaShareButton from "./buttons/mediaShare";
import LeaveButton from "./buttons/leave"; import LeaveButton from "./buttons/leave";
import { import {
setCallIsPopout, setCallIsPopout,
@ -31,7 +33,7 @@ import {
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
export default function Actions() { export default function Actions() {
const sharedClasses = "w-14 h-10"; const sharedClasses = "w-14! h-10!";
const sharedIconSize = 15; const sharedIconSize = 15;
const view = useCall((state) => state.view); const view = useCall((state) => state.view);
const focusedParticipantId = useCall((state) => state.focusedParticipantId); const focusedParticipantId = useCall((state) => state.focusedParticipantId);
@ -77,34 +79,45 @@ export default function Actions() {
(state) => state.usersInFocusedViewHidden, (state) => state.usersInFocusedViewHidden,
); );
const isMobile = useIsMobile();
return ( return (
<div className="w-full flex justify-between items-center"> <div
<div className="w-30 flex justify-start"> className={cn(
{view === "focused" && ( "w-full flex items-center",
<Tooltip> isMobile ? "justify-center" : "justify-between",
<TooltipTrigger )}
render={ >
<Button {!isMobile && (
onClick={() => <div className="w-30 flex justify-start">
setUsersInFocusedViewHidden(!usersInFocusedViewHidden) {view === "focused" && (
} <Tooltip>
variant="link" <TooltipTrigger
className="w-11 h-11 p-0! ml-3 text-foreground" render={({ ref, onClick }) => (
> <Button
{usersInFocusedViewHidden ? ( ref={ref as React.Ref<HTMLButtonElement>}
<ChevronUp className="size-6" /> onClick={(event) => {
) : ( onClick?.(event);
<ChevronDown className="size-6" /> setUsersInFocusedViewHidden(!usersInFocusedViewHidden);
)} }}
</Button> variant="ghost"
} className="w-11 h-11! p-0! ml-3 text-foreground border-0!"
/> >
<TooltipContent portalProps={{ container: portalContainer }}> {usersInFocusedViewHidden ? (
Hide users <ChevronUp className="size-6" />
</TooltipContent> ) : (
</Tooltip> <ChevronDown className="size-6" />
)} )}
</div> </Button>
)}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Hide users
</TooltipContent>
</Tooltip>
)}
</div>
)}
<Card className="p-1.75"> <Card className="p-1.75">
<CardContent className="p-0! flex gap-1.75"> <CardContent className="p-0! flex gap-1.75">
<MuteButton <MuteButton
@ -119,10 +132,10 @@ export default function Actions() {
tooltip="Deafen" tooltip="Deafen"
portalContainer={portalContainer} portalContainer={portalContainer}
/> />
<ScreenshareButton <MediaShareButton
className={sharedClasses} className={sharedClasses}
iconSize={sharedIconSize} iconSize={sharedIconSize}
tooltip="Screenshare" tooltip="Share media"
/> />
<InviteButton <InviteButton
className={sharedClasses} className={sharedClasses}
@ -132,11 +145,15 @@ export default function Actions() {
{isWatchingFocusedStream && focusedParticipantId !== ownId ? ( {isWatchingFocusedStream && focusedParticipantId !== ownId ? (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={({ ref, onClick }) => (
<Button <Button
ref={ref}
className={sharedClasses} className={sharedClasses}
variant="destructive" variant="destructive"
onClick={() => stopWatchingFocusedStream()} onClick={(event) => {
onClick?.(event);
stopWatchingFocusedStream();
}}
> >
<ScreenShareOff <ScreenShareOff
style={{ style={{
@ -145,7 +162,7 @@ export default function Actions() {
}} }}
/> />
</Button> </Button>
} )}
/> />
<TooltipContent portalProps={{ container: portalContainer }}> <TooltipContent portalProps={{ container: portalContainer }}>
Stop watching Stop watching
@ -161,50 +178,58 @@ export default function Actions() {
)} )}
</CardContent> </CardContent>
</Card> </Card>
<div className="w-30 flex justify-end"> {!isMobile && (
<Tooltip> <div className="w-30 flex justify-end gap-1.5">
<TooltipTrigger <Tooltip>
render={ <TooltipTrigger
<Button render={({ ref, onClick }) => (
onClick={() => setCallIsPopout(!callIsPopout)} <Button
variant="link" ref={ref}
className="w-11 h-11 p-0! text-foreground" onClick={(event) => {
> onClick?.(event);
{callIsPopout ? ( setCallIsPopout(!callIsPopout);
<SquareArrowOutDownLeft className="size-6" /> }}
) : ( variant="ghost"
<SquareArrowOutUpRight className="size-6" /> className="w-11 h-11! p-0! text-foreground border-0!"
)} >
</Button> {callIsPopout ? (
} <SquareArrowOutDownLeft className="size-6" />
/> ) : (
<TooltipContent portalProps={{ container: portalContainer }}> <SquareArrowOutUpRight className="size-6" />
Popout )}
</TooltipContent> </Button>
</Tooltip> )}
<Tooltip> />
<TooltipTrigger <TooltipContent portalProps={{ container: portalContainer }}>
render={ Popout
<Button </TooltipContent>
onClick={() => { </Tooltip>
void toggleFullscreen(); <Tooltip>
}} <TooltipTrigger
variant="link" render={({ ref, onClick }) => (
className="w-11 h-11 p-0! mr-3 text-foreground" <Button
> ref={ref}
{callIsFullscreen ? ( onClick={(event) => {
<Minimize className="size-6" /> onClick?.(event);
) : ( void toggleFullscreen();
<Maximize className="size-6" /> }}
)} variant="ghost"
</Button> className="w-11 h-11! p-0! mr-3 text-foreground border-0!"
} >
/> {callIsFullscreen ? (
<TooltipContent portalProps={{ container: portalContainer }}> <Minimize className="size-6" />
Fullscreen ) : (
</TooltipContent> <Maximize className="size-6" />
</Tooltip> )}
</div> </Button>
)}
/>
<TooltipContent portalProps={{ container: portalContainer }}>
Fullscreen
</TooltipContent>
</Tooltip>
</div>
)}
</div> </div>
); );
} }

View file

@ -1,7 +1,11 @@
import { Button } from "@tensamin/ui"; import { Button } from "@methanium/ui";
import { toggleDeaf, useCall } from "../../store"; import { toggleDeaf, useCall } from "../../store";
import { HeadphoneOff, Headphones } from "lucide-react"; import { HeadphoneOff, Headphones } from "lucide-react";
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton"; import {
type CallButtonProps,
iconScale,
withButtonTooltip,
} from "./tooltipButton";
export default function DeafButton({ export default function DeafButton({
className, className,
@ -25,5 +29,5 @@ export default function DeafButton({
</Button> </Button>
); );
return withTooltip(button, tooltip, portalContainer); return withButtonTooltip(button, tooltip, portalContainer);
} }

View file

@ -7,7 +7,7 @@ import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@tensamin/ui"; } from "@methanium/ui";
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/user/wrapper";
import { Mail } from "lucide-react"; import { Mail } from "lucide-react";
import { sendCallInvite, useCall } from "../../store"; import { sendCallInvite, useCall } from "../../store";
@ -38,13 +38,13 @@ export default function InviteButton({
render={ render={
tooltip ? ( tooltip ? (
<TooltipTrigger <TooltipTrigger
render={ render={({ ref, onClick }) => (
<Button className={className}> <Button ref={ref} onClick={onClick} className={className}>
<Mail <Mail
style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }} style={{ scale: (iconSize ? iconSize + 100 : 100) + "%" }}
/> />
</Button> </Button>
} )}
/> />
) : ( ) : (
<Button className={className}> <Button className={className}>

View file

@ -1,7 +1,11 @@
import { LeaveIcon } from "@livekit/components-react"; import { LeaveIcon } from "@livekit/components-react";
import { Button } from "@tensamin/ui"; import { Button } from "@methanium/ui";
import { disconnect, useCall } from "../../store"; import { disconnect, useCall } from "../../store";
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton"; import {
type CallButtonProps,
iconScale,
withButtonTooltip,
} from "./tooltipButton";
export default function LeaveButton({ export default function LeaveButton({
className, className,
@ -22,5 +26,5 @@ export default function LeaveButton({
</Button> </Button>
); );
return withTooltip(button, tooltip, portalContainer); return withButtonTooltip(button, tooltip, portalContainer);
} }

View 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}
</>
);
}

View file

@ -1,7 +1,11 @@
import { Button } from "@tensamin/ui"; import { Button } from "@methanium/ui";
import { toggleMute, useCall } from "../../store"; import { toggleMute, useCall } from "../../store";
import { Mic, MicOff } from "lucide-react"; import { Mic, MicOff } from "lucide-react";
import { type CallButtonProps, iconScale, withTooltip } from "./tooltipButton"; import {
type CallButtonProps,
iconScale,
withButtonTooltip,
} from "./tooltipButton";
export default function MuteButton({ export default function MuteButton({
className, className,
@ -25,5 +29,5 @@ export default function MuteButton({
</Button> </Button>
); );
return withTooltip(button, tooltip, portalContainer); return withButtonTooltip(button, tooltip, portalContainer);
} }

View file

@ -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}
/>
)}
</>
);
}

View file

@ -1,5 +1,6 @@
import { type ReactElement } from "react"; import { cloneElement } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui"; import type { MouseEvent as ReactMouseEvent, ReactElement, Ref } from "react";
import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
export type CallButtonProps = { export type CallButtonProps = {
className?: string; className?: string;
@ -12,20 +13,31 @@ export function iconScale(iconSize?: number) {
return { scale: (iconSize ? iconSize + 100 : 100) + "%" }; return { scale: (iconSize ? iconSize + 100 : 100) + "%" };
} }
export function withTooltip( export function withButtonTooltip(
button: ReactElement, trigger: ReactElement<{
tooltip?: string, onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
ref?: Ref<HTMLElement>;
}>,
content?: string,
portalContainer?: HTMLElement, portalContainer?: HTMLElement,
) { ) {
if (!tooltip) { if (!content) return trigger;
return button;
}
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger render={button} /> <TooltipTrigger
render={({ ref, onClick }) =>
cloneElement(trigger, {
ref,
onClick: (event) => {
onClick?.(event);
trigger.props.onClick?.(event);
},
})
}
/>
<TooltipContent portalProps={{ container: portalContainer }}> <TooltipContent portalProps={{ container: portalContainer }}>
{tooltip} {content}
</TooltipContent> </TooltipContent>
</Tooltip> </Tooltip>
); );

View file

@ -5,7 +5,7 @@ import {
Button, Button,
Dialog, Dialog,
DialogContent, DialogContent,
} from "@tensamin/ui"; } from "@methanium/ui";
import Wrapper from "@tensamin/user/wrapper"; import Wrapper from "@tensamin/user/wrapper";
import { PhoneIncoming, X } from "lucide-react"; import { PhoneIncoming, X } from "lucide-react";
@ -26,7 +26,10 @@ export default function InvitePopup({
loading={null} loading={null}
component={(user) => ( component={(user) => (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent className="flex flex-col gap-5 items-center justify-center w-65 h-80"> <DialogContent
showCloseButton={false}
className="flex flex-col gap-5 items-center justify-center w-65 h-80"
>
<Avatar className="size-30"> <Avatar className="size-30">
<AvatarImage src={user.Avatar} /> <AvatarImage src={user.Avatar} />
<AvatarFallback className="text-5xl"> <AvatarFallback className="text-5xl">
@ -36,14 +39,14 @@ export default function InvitePopup({
<p className="text-xl font-medium">{user.Display}</p> <p className="text-xl font-medium">{user.Display}</p>
<div className="w-full flex justify-center gap-3"> <div className="w-full flex justify-center gap-3">
<Button <Button
className="w-14 h-14" className="w-13 h-13!"
variant="destructive" variant="destructive"
onClick={() => onAccept(false)} onClick={() => onAccept(false)}
> >
<X className="size-5" /> <X className="size-5" />
</Button> </Button>
<Button <Button
className="w-14 h-14" className="w-13 h-13!"
variant="subtleDefault" variant="subtleDefault"
onClick={() => onAccept(true)} onClick={() => onAccept(true)}
> >

View 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>
);
}

View 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>
);
}

View file

@ -5,7 +5,8 @@ import {
Button, Button,
ContextMenu as UIContextMenu, ContextMenu as UIContextMenu,
ContextMenuTrigger, ContextMenuTrigger,
} from "@tensamin/ui"; cn,
} from "@methanium/ui";
import { import {
focusParticipant, focusParticipant,
getRoomMetadata, getRoomMetadata,
@ -43,7 +44,7 @@ function TransparentButton({ children }: { children: React.ReactNode }) {
); );
} }
function getAverageImageColor(src: string) { export function getAverageImageColor(src: string) {
return new Promise<string | undefined>((resolve) => { return new Promise<string | undefined>((resolve) => {
const image = new Image(); const image = new Image();
@ -177,6 +178,13 @@ export default function Base({
Track.Source.ScreenShare, Track.Source.ScreenShare,
); );
const screenSharePreview = participant?.attributes["screenSharePreview"]; const screenSharePreview = participant?.attributes["screenSharePreview"];
const cameraPublication = getTrackPublicationBySource(
participant,
Track.Source.Camera,
);
const cameraDisabled = useCall((state) =>
user ? state.disabledCameraParticipantIds.includes(user.UserId) : false,
);
const [ownId, setOwnId] = useState(0); const [ownId, setOwnId] = useState(0);
useEffect(() => { useEffect(() => {
@ -264,9 +272,14 @@ export default function Base({
render={ render={
<div <div
onClick={onClick} onClick={onClick}
className={`relative w-full ${fill ? "h-full border-0" : "aspect-video border-2"} ${ className={cn(
flush || fill ? "rounded-none" : "rounded-md" "relative w-full",
} ${flush && !fill ? "border-x-0" : ""}`} fill ? "h-full" : "aspect-video",
flush && !fill && "border-x-0!",
type === "user" &&
isSpeaking &&
"border-4 border-(--primary-foreground-alt)/75! rounded-lg",
)}
> >
<div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2"> <div className="z-20 absolute bottom-0 left-0 w-full h-full flex justify-start items-end p-2">
<> <>
@ -303,8 +316,8 @@ export default function Base({
<div <div
ref={currentCard} ref={currentCard}
className={`transition-all duration-150 z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${ className={`transition-all duration-150 z-10 bg-card absolute top-0 left-0 w-full h-full flex gap-2 items-center justify-center ${
flush ? "rounded-none" : "rounded-sm" flush ? "rounded-none" : "rounded-lg"
} ${type === "user" && isSpeaking ? "border-4 border-(--primary-foreground-alt)/75" : "border-0"}`} }`}
style={{ style={{
backgroundColor: avatarBackgroundColor, backgroundColor: avatarBackgroundColor,
containerType: "size", containerType: "size",
@ -335,23 +348,31 @@ export default function Base({
</div> </div>
) : null)} ) : null)}
{type === "user" && ( {type === "user" &&
<Avatar (cameraPublication?.track && !cameraDisabled ? (
style={{ <VideoViewer
width: "32cqh", fill={fill}
height: "32cqh", flush={flush}
}} participantId={participant.identity}
> publication={cameraPublication}
<AvatarImage src={user.Avatar} /> />
<AvatarFallback ) : (
<Avatar
style={{ style={{
fontSize: "11cqh", width: "32cqh",
height: "32cqh",
}} }}
> >
{user.Display.slice(0, 2).toUpperCase()} <AvatarImage src={user.Avatar} />
</AvatarFallback> <AvatarFallback
</Avatar> style={{
)} fontSize: "11cqh",
}}
>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
))}
</div> </div>
</div> </div>
} }

View file

@ -4,9 +4,9 @@ import {
ContextMenuItem, ContextMenuItem,
ContextMenuSeparator, ContextMenuSeparator,
Slider, Slider,
} from "@tensamin/ui"; } from "@methanium/ui";
import type { User } from "@tensamin/user/context"; import type { User } from "@tensamin/user/context";
import { useCall } from "../../store"; import { setParticipantCameraDisabled, useCall } from "../../store";
import { useState } from "react"; import { useState } from "react";
function EmptyCheckboxIndicator({ checked }: { checked: boolean }) { function EmptyCheckboxIndicator({ checked }: { checked: boolean }) {
@ -33,6 +33,9 @@ export default function ContextMenu({
const watchedStreamParticipantIds = useCall( const watchedStreamParticipantIds = useCall(
(state) => state.watchedStreamParticipantIds, (state) => state.watchedStreamParticipantIds,
); );
const cameraDisabled = useCall((state) =>
state.disabledCameraParticipantIds.includes(user.UserId),
);
return ( return (
<ContextMenuContent className="p-1"> <ContextMenuContent className="p-1">
@ -71,6 +74,19 @@ export default function ContextMenu({
<p>Mute Soundboard</p> <p>Mute Soundboard</p>
<EmptyCheckboxIndicator checked={soundboardMuted} /> <EmptyCheckboxIndicator checked={soundboardMuted} />
</ContextMenuCheckboxItem> </ContextMenuCheckboxItem>
{user.UserId !== ownId ? (
<ContextMenuCheckboxItem
checked={cameraDisabled}
onCheckedChange={(checked) =>
setParticipantCameraDisabled(user.UserId, checked)
}
onSelect={(e) => e.preventDefault()}
className="flex justify-between"
>
<p>Disable camera</p>
<EmptyCheckboxIndicator checked={cameraDisabled} />
</ContextMenuCheckboxItem>
) : null}
<ContextMenuCheckboxItem <ContextMenuCheckboxItem
checked={serverDeafened} checked={serverDeafened}
onCheckedChange={setServerDeafened} onCheckedChange={setServerDeafened}

View file

@ -1,10 +1,22 @@
import { Participant, Track } from "livekit-client"; import { Participant, Track } from "livekit-client";
import VideoViewer from "./videoViewer"; import VideoViewer from "./videoViewer";
import { useLocation } from "@tanstack/react-router"; import { useLocation } from "@tanstack/react-router";
import { getRoom, stopWatchingStream, useCall } from "../store"; import { getRoom, openCallPage, stopWatchingStream, useCall } from "../store";
import { useState, useRef, useEffect, useCallback } from "react"; import { useState, useRef, useEffect, useCallback } from "react";
import { Button, cn } from "@tensamin/ui"; import {
Avatar,
AvatarFallback,
AvatarImage,
Button,
Card,
cn,
useIsMobile,
useSidebar,
} from "@methanium/ui";
import { ScreenShareOff } from "lucide-react"; import { ScreenShareOff } from "lucide-react";
import { useUser, type User } from "@tensamin/user/context";
import { useIsSpeaking, useLastSpeakingParticipantId } from "../speakingState";
import { getAverageImageColor } from "./modals/base";
function getTrackPublicationBySource( function getTrackPublicationBySource(
participant: Participant | undefined, participant: Participant | undefined,
@ -38,6 +50,237 @@ const MARGIN = 40;
const MIN_SIZE = 240; const MIN_SIZE = 240;
const MAX_SIZE = 1000; const MAX_SIZE = 1000;
const ASPECT_RATIO = 9 / 16; const ASPECT_RATIO = 9 / 16;
const MOBILE_MARGIN = 16;
const MOBILE_PILL_WIDTH = 128;
const MOBILE_PILL_HEIGHT = 48;
function MobileCallPill({
active,
callId,
}: {
active: boolean;
callId: string;
}) {
const { setOpenMobile } = useSidebar();
const { get } = useUser();
const lastSpeakingParticipantId = useLastSpeakingParticipantId();
const isSpeaking = useIsSpeaking(lastSpeakingParticipantId ?? -1);
const [lastSpeakingUser, setLastSpeakingUser] = useState<User | null>(null);
const [avatarBackgroundColor, setAvatarBackgroundColor] = useState<
string | undefined
>(undefined);
const safeAreaRef = useRef<HTMLDivElement>(null);
const pillRef = useRef<HTMLDivElement>(null);
const initialCoords = {
x: window.innerWidth - MOBILE_PILL_WIDTH - MOBILE_MARGIN,
y: MOBILE_MARGIN,
};
const coordsRef = useRef<Point>(initialCoords);
const dragOffsetRef = useRef<Point>({ x: 0, y: 0 });
const dragStartRef = useRef<Point>({ x: 0, y: 0 });
const movedRef = useRef(false);
const [position, setPosition] = useState<Positions>("top-right");
const [coords, setCoords] = useState(initialCoords);
const [isDragging, setIsDragging] = useState(false);
useEffect(() => {
if (lastSpeakingParticipantId == null) {
setLastSpeakingUser(null);
return;
}
let mounted = true;
void get(lastSpeakingParticipantId).then((user) => {
if (mounted) {
setLastSpeakingUser(user);
}
});
return () => {
mounted = false;
};
}, [get, lastSpeakingParticipantId]);
useEffect(() => {
if (!lastSpeakingUser?.Avatar) {
setAvatarBackgroundColor(undefined);
return;
}
let mounted = true;
void getAverageImageColor(lastSpeakingUser.Avatar).then((color) => {
if (mounted) {
setAvatarBackgroundColor(color);
}
});
return () => {
mounted = false;
};
}, [lastSpeakingUser?.Avatar]);
const getSafeArea = useCallback(() => {
const element = safeAreaRef.current;
if (!element) return { top: 0, right: 0, bottom: 0, left: 0 };
const style = getComputedStyle(element);
return {
top: parseFloat(style.paddingTop) || 0,
right: parseFloat(style.paddingRight) || 0,
bottom: parseFloat(style.paddingBottom) || 0,
left: parseFloat(style.paddingLeft) || 0,
};
}, []);
const getCoordsForPosition = useCallback(
(nextPosition: Positions): Point => {
const bounds = pillRef.current?.getBoundingClientRect();
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
const safeArea = getSafeArea();
return {
x: nextPosition.endsWith("right")
? window.innerWidth - safeArea.right - width - MOBILE_MARGIN
: safeArea.left + MOBILE_MARGIN,
y: nextPosition.startsWith("bottom")
? window.innerHeight - safeArea.bottom - height - MOBILE_MARGIN
: safeArea.top + MOBILE_MARGIN,
};
},
[getSafeArea],
);
const setCoordsSafe = useCallback((next: Point) => {
coordsRef.current = next;
setCoords(next);
}, []);
const snapToPosition = useCallback(
(nextPosition: Positions) => {
setPosition(nextPosition);
setCoordsSafe(getCoordsForPosition(nextPosition));
},
[getCoordsForPosition, setCoordsSafe],
);
useEffect(() => {
if (active && !isDragging) {
snapToPosition(position);
}
}, [active, isDragging, position, snapToPosition]);
useEffect(() => {
const handleResize = () => snapToPosition(position);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, [position, snapToPosition]);
if (!active) {
return null;
}
return (
<div
ref={safeAreaRef}
className="pointer-events-none fixed inset-0 z-200 pt-[env(safe-area-inset-top)] pr-[env(safe-area-inset-right)] pb-[env(safe-area-inset-bottom)] pl-[env(safe-area-inset-left)]"
>
<Card
ref={pillRef}
onClick={() => {
if (movedRef.current) {
movedRef.current = false;
return;
}
setOpenMobile(false);
void openCallPage(callId);
}}
onPointerDown={(event) => {
if (event.button !== 0) return;
event.currentTarget.setPointerCapture(event.pointerId);
dragOffsetRef.current = {
x: event.clientX - coordsRef.current.x,
y: event.clientY - coordsRef.current.y,
};
dragStartRef.current = { x: event.clientX, y: event.clientY };
movedRef.current = false;
setIsDragging(true);
}}
onPointerMove={(event) => {
if (!isDragging) return;
const bounds = pillRef.current?.getBoundingClientRect();
const width = bounds?.width ?? MOBILE_PILL_WIDTH;
const height = bounds?.height ?? MOBILE_PILL_HEIGHT;
const safeArea = getSafeArea();
const next = {
x: Math.min(
window.innerWidth - safeArea.right - width - MOBILE_MARGIN,
Math.max(
safeArea.left + MOBILE_MARGIN,
event.clientX - dragOffsetRef.current.x,
),
),
y: Math.min(
window.innerHeight - safeArea.bottom - height - MOBILE_MARGIN,
Math.max(
safeArea.top + MOBILE_MARGIN,
event.clientY - dragOffsetRef.current.y,
),
),
};
if (
Math.abs(event.clientX - dragStartRef.current.x) > 3 ||
Math.abs(event.clientY - dragStartRef.current.y) > 3
) {
movedRef.current = true;
}
setCoordsSafe(next);
}}
onPointerUp={(event) => {
if (!isDragging) return;
const nextPosition = `${
event.clientY < window.innerHeight / 2 ? "top" : "bottom"
}-${event.clientX < window.innerWidth / 2 ? "left" : "right"}` as Positions;
setIsDragging(false);
snapToPosition(nextPosition);
}}
onPointerCancel={() => {
setIsDragging(false);
snapToPosition(position);
}}
className={cn(
"pointer-events-auto fixed left-0 top-0 z-200 flex w-23 h-23! touch-none select-none shadow-xl rounded-2xl flex items-center justify-center",
isSpeaking && "border-3! border-(--primary-foreground-alt)/75!",
isDragging ? "cursor-grabbing" : "cursor-grab",
)}
style={{
backgroundColor: avatarBackgroundColor,
transform: `translate3d(${coords.x}px, ${coords.y}px, 0)`,
transition: isDragging
? "none"
: "transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1)",
willChange: "transform",
}}
>
<Avatar className="size-14">
<AvatarImage src={lastSpeakingUser?.Avatar} />
<AvatarFallback className="text-lg">
{lastSpeakingUser?.Display.slice(0, 2).toUpperCase() ?? "..."}
</AvatarFallback>
</Avatar>
</Card>
</div>
);
}
export function Popout({ participant }: { participant: Participant }) { export function Popout({ participant }: { participant: Participant }) {
const screenSharePublication = getTrackPublicationBySource( const screenSharePublication = getTrackPublicationBySource(
@ -455,7 +698,10 @@ export function Popout({ participant }: { participant: Participant }) {
export default function Wrapper() { export default function Wrapper() {
const room = getRoom(); const room = getRoom();
const { pathname } = useLocation(); const { pathname } = useLocation();
const { openMobile } = useSidebar();
const isMobile = useIsMobile();
const state = useCall((state) => state.state); const state = useCall((state) => state.state);
const callId = useCall((state) => state.callId);
const watchedStreamParticipantIds = useCall( const watchedStreamParticipantIds = useCall(
(state) => state.watchedStreamParticipantIds, (state) => state.watchedStreamParticipantIds,
); );
@ -466,6 +712,17 @@ export default function Wrapper() {
String(lastFocusedParticipantId), String(lastFocusedParticipantId),
); );
if (isMobile) {
return (
<MobileCallPill
active={
(!pathname.startsWith("/call") || openMobile) && state === "open"
}
callId={callId ?? ""}
/>
);
}
if (!participant || !lastFocusedParticipantId) { if (!participant || !lastFocusedParticipantId) {
return null; return null;
} }

View file

@ -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>
);
}

View file

@ -9,8 +9,8 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
useIsMobile, useIsMobile,
} from "@tensamin/ui"; } from "@methanium/ui";
import ScreenshareButton from "./buttons/screenshare"; import MediaShareButton from "./buttons/mediaShare";
import MuteButton from "./buttons/mute"; import MuteButton from "./buttons/mute";
import DeafButton from "./buttons/deaf"; import DeafButton from "./buttons/deaf";
import { Room, Track } from "livekit-client"; import { Room, Track } from "livekit-client";
@ -18,25 +18,36 @@ import { useEffect, useState } from "react";
import { AreaChart, Area } from "recharts"; import { AreaChart, Area } from "recharts";
import LeaveButton from "./buttons/leave"; import LeaveButton from "./buttons/leave";
export default function SidebarBox() { async function getPing(room: Room): Promise<number | undefined> {
const state = useCall((store) => store.state); const report = await room.localParticipant
const isMobile = useIsMobile(); .getTrackPublication(Track.Source.Microphone)
?.track?.getRTCStatsReport();
return state === "closed" ? null : ( if (!report) return;
<Card className="p-1.5 gap-2" hidden={isMobile}>
<CardHeader className="p-0! pb-2! border-b-2"> let bestRtt: number | undefined;
<ConnectionBar />
</CardHeader> report.forEach((stat) => {
<CardContent className="p-0! flex flex-col gap-1"> if (
<div className="flex justify-start gap-1"> stat.type === "candidate-pair" &&
<MuteButton className="w-9 h-9" /> stat.state === "succeeded" &&
<DeafButton className="w-9 h-9" /> stat.currentRoundTripTime != null
<ScreenshareButton className="w-9 h-9" defaultPortal /> ) {
<LeaveButton className="w-9 h-9" /> bestRtt = stat.currentRoundTripTime * 1000;
</div> }
</CardContent>
</Card> if (stat.type === "remote-inbound-rtp" && stat.roundTripTime != null) {
); bestRtt = stat.roundTripTime * 1000;
}
});
if (bestRtt == null || bestRtt <= 0) return;
const roundedRtt = Math.round(bestRtt);
if (roundedRtt <= 0) return;
return roundedRtt;
} }
function ConnectionBar() { function ConnectionBar() {
@ -47,14 +58,18 @@ function ConnectionBar() {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger <TooltipTrigger
render={ render={({ ref, onClick }) => (
<Button <Button
onClick={() => openCallPage(callId || "")} ref={ref as React.Ref<HTMLButtonElement>}
onClick={(event) => {
onClick?.(event);
openCallPage(callId || "");
}}
size="lg" size="lg"
variant={ variant={
state === "open" && isEncrypted ? "subtleDefault" : "destructive" state === "open" && isEncrypted ? "subtleDefault" : "destructive"
} }
className="flex justify-between items-center" className="flex items-center justify-between px-2!"
> >
{state === "encrypting" && "Encrypting..."} {state === "encrypting" && "Encrypting..."}
{state === "connecting" && "Connecting..."} {state === "connecting" && "Connecting..."}
@ -70,7 +85,7 @@ function ConnectionBar() {
<LockOpen color="var(--destructive)" /> <LockOpen color="var(--destructive)" />
)} )}
</Button> </Button>
} )}
/> />
<TooltipContent>Click to open call page</TooltipContent> <TooltipContent>Click to open call page</TooltipContent>
</Tooltip> </Tooltip>
@ -171,34 +186,23 @@ export function TinyPingGraph() {
); );
} }
async function getPing(room: Room): Promise<number | undefined> { export default function SidebarBox() {
const report = await room.localParticipant const state = useCall((store) => store.state);
.getTrackPublication(Track.Source.Microphone) const isMobile = useIsMobile();
?.track?.getRTCStatsReport();
if (!report) return; return state === "closed" ? null : (
<Card className="p-1.5 gap-2" hidden={isMobile}>
let bestRtt: number | undefined; <CardHeader className="p-0! pb-2! border-b-2">
<ConnectionBar />
report.forEach((stat) => { </CardHeader>
if ( <CardContent className="p-0! flex flex-col gap-1">
stat.type === "candidate-pair" && <div className="flex justify-center gap-1">
stat.state === "succeeded" && <MuteButton className="h-9! w-[24%]" />
stat.currentRoundTripTime != null <DeafButton className="h-9! w-[24%]" />
) { <MediaShareButton className="h-9! w-[24%]!" defaultPortal />
bestRtt = stat.currentRoundTripTime * 1000; <LeaveButton className="h-9! w-[24%]" />
} </div>
</CardContent>
if (stat.type === "remote-inbound-rtp" && stat.roundTripTime != null) { </Card>
bestRtt = stat.roundTripTime * 1000; );
}
});
if (bestRtt == null || bestRtt <= 0) return;
const roundedRtt = Math.round(bestRtt);
if (roundedRtt <= 0) return;
return roundedRtt;
} }

View file

@ -9,7 +9,7 @@ import {
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
} from "@tensamin/ui"; } from "@methanium/ui";
export default function TopBar() { export default function TopBar() {
const { get } = useUser(); const { get } = useUser();

View file

@ -1,7 +1,7 @@
import { VideoTrack, useParticipantTracks } from "@livekit/components-react"; import { VideoTrack, useParticipantTracks } from "@livekit/components-react";
import { TrackPublication } from "livekit-client"; import { TrackPublication } from "livekit-client";
import { getRoom } from "../store"; import { getRoom } from "../store";
import { cn } from "@tensamin/ui"; import { cn } from "@methanium/ui";
import { Loader2 } from "lucide-react"; import { Loader2 } from "lucide-react";
export default function VideoViewer({ export default function VideoViewer({

View 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);
}
}

View 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 };
}

View 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 });
}
}

View 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";

View 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);
}
}

View 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>;
}

View file

@ -31,8 +31,13 @@ function copyDocumentStyles(targetDocument: Document) {
} }
} }
function syncDocumentClasses(targetDocument: Document) { function syncDocumentAttributes(targetDocument: Document) {
targetDocument.documentElement.className = document.documentElement.className; for (const attribute of document.documentElement.attributes) {
targetDocument.documentElement.setAttribute(
attribute.name,
attribute.value,
);
}
targetDocument.body.className = document.body.className; targetDocument.body.className = document.body.className;
} }
@ -58,12 +63,12 @@ function PopoutScreen() {
popoutWindow.document.title = document.title; popoutWindow.document.title = document.title;
popoutWindow.document.body.innerHTML = ""; popoutWindow.document.body.innerHTML = "";
popoutWindow.document.body.style.margin = "0"; popoutWindow.document.body.style.margin = "0";
copyDocumentStyles(popoutWindow.document);
syncDocumentAttributes(popoutWindow.document);
popoutWindow.document.documentElement.style.height = "100%"; popoutWindow.document.documentElement.style.height = "100%";
popoutWindow.document.body.style.height = "100%"; popoutWindow.document.body.style.height = "100%";
copyDocumentStyles(popoutWindow.document);
syncDocumentClasses(popoutWindow.document);
const containerElement = popoutWindow.document.createElement("div"); const containerElement = popoutWindow.document.createElement("div");
containerElement.style.width = "100%"; containerElement.style.width = "100%";
containerElement.style.height = "100%"; containerElement.style.height = "100%";

View file

@ -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,
};
}

View file

@ -2,11 +2,13 @@ import { create } from "zustand";
type SpeakingState = { type SpeakingState = {
speakingParticipantIds: Set<number>; speakingParticipantIds: Set<number>;
lastSpeakingParticipantId: number | null;
micGated: boolean; micGated: boolean;
}; };
const useSpeakingState = create<SpeakingState>(() => ({ const useSpeakingState = create<SpeakingState>(() => ({
speakingParticipantIds: new Set(), speakingParticipantIds: new Set(),
lastSpeakingParticipantId: null,
micGated: false, micGated: false,
})); }));
@ -20,10 +22,19 @@ export function clearSpeakingParticipants() {
export function removeSpeakingParticipant(participantId: number) { export function removeSpeakingParticipant(participantId: number) {
useSpeakingState.setState((state) => { useSpeakingState.setState((state) => {
if (!state.speakingParticipantIds.has(participantId)) return state; const wasSpeaking = state.speakingParticipantIds.has(participantId);
const wasLastSpeaking = state.lastSpeakingParticipantId === participantId;
if (!wasSpeaking && !wasLastSpeaking) return state;
const next = new Set(state.speakingParticipantIds); const next = new Set(state.speakingParticipantIds);
next.delete(participantId); next.delete(participantId);
return { speakingParticipantIds: next }; return {
speakingParticipantIds: next,
lastSpeakingParticipantId: wasLastSpeaking
? null
: state.lastSpeakingParticipantId,
};
}); });
} }
@ -31,11 +42,13 @@ export function updateSpeakingParticipants(changed: Map<number, boolean>) {
useSpeakingState.setState((state) => { useSpeakingState.setState((state) => {
let hasDiff = false; let hasDiff = false;
const next = new Set(state.speakingParticipantIds); const next = new Set(state.speakingParticipantIds);
let lastSpeakingParticipantId = state.lastSpeakingParticipantId;
for (const [id, speaking] of changed) { for (const [id, speaking] of changed) {
if (speaking) { if (speaking) {
if (!next.has(id)) { if (!next.has(id)) {
next.add(id); next.add(id);
lastSpeakingParticipantId = id;
hasDiff = true; hasDiff = true;
} }
} else if (next.has(id)) { } else if (next.has(id)) {
@ -44,10 +57,16 @@ export function updateSpeakingParticipants(changed: Map<number, boolean>) {
} }
} }
return hasDiff ? { speakingParticipantIds: next } : state; return hasDiff
? { speakingParticipantIds: next, lastSpeakingParticipantId }
: state;
}); });
} }
export function useLastSpeakingParticipantId(): number | null {
return useSpeakingState((state) => state.lastSpeakingParticipantId);
}
export function useIsSpeaking(participantId: number): boolean { export function useIsSpeaking(participantId: number): boolean {
return useSpeakingState((state) => return useSpeakingState((state) =>
state.speakingParticipantIds.has(participantId), state.speakingParticipantIds.has(participantId),

View file

@ -4,6 +4,7 @@ import { useLocation, useNavigate } from "@tanstack/react-router";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log"; import { log, toast } from "@tensamin/shared/log";
import { mtp } from "@tensamin/shared/data"; import { mtp } from "@tensamin/shared/data";
import { playSound, stopSound } from "@tensamin/shared/sounds";
import { bytesToBase64 } from "mtp"; import { bytesToBase64 } from "mtp";
import { import {
deriveCallSecretId, deriveCallSecretId,
@ -25,16 +26,16 @@ import {
Room, Room,
RoomEvent, RoomEvent,
type RemoteTrack, type RemoteTrack,
type ScreenShareCaptureOptions,
Track, Track,
setLogExtension, setLogExtension,
getLogger, getLogger,
} from "livekit-client"; } from "livekit-client";
import z from "zod"; import z from "zod";
import { import {
createScreenShareController, createMediaShareController,
type ScreenShareSession, type LocalMediaShareSession,
} from "./screenshare"; } from "./mediaShare/controller";
import type { MediaShareRequest } from "./mediaShare";
import { import {
getSpeakingDetector, getSpeakingDetector,
disposeSpeakingDetector, disposeSpeakingDetector,
@ -100,8 +101,11 @@ type CallStore = {
currentCallData: CurrentCallData; currentCallData: CurrentCallData;
deaf: boolean; deaf: boolean;
micEnabled: boolean; micEnabled: boolean;
cameraEnabled: boolean;
screenShareEnabled: boolean; screenShareEnabled: boolean;
screenShareSession: ScreenShareSession | null; screenShareSession: LocalMediaShareSession | null;
cameraSession: LocalMediaShareSession | null;
disabledCameraParticipantIds: number[];
focusedParticipantId: number | null; focusedParticipantId: number | null;
focusedParticipantType: "user" | "stream" | null; focusedParticipantType: "user" | "stream" | null;
usersInFocusedViewHidden: boolean; usersInFocusedViewHidden: boolean;
@ -154,6 +158,8 @@ export function getRoom(): Room {
} }
const remoteAudioElements = new Map<string, HTMLMediaElement>(); const remoteAudioElements = new Map<string, HTMLMediaElement>();
let callJingle: HTMLAudioElement | null = null;
let callJingleGeneration = 0;
const CALL_SECRET_VERSION = 1; const CALL_SECRET_VERSION = 1;
const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320; const SCREEN_SHARE_PREVIEW_MAX_WIDTH = 320;
@ -161,6 +167,31 @@ const SCREEN_SHARE_PREVIEW_MAX_HEIGHT = 180;
const SCREEN_SHARE_PREVIEW_QUALITY = 0.7; const SCREEN_SHARE_PREVIEW_QUALITY = 0.7;
const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000; const SCREEN_SHARE_PREVIEW_TIMEOUT_MS = 5000;
async function startCallJingle(shouldPlay: () => boolean) {
const generation = ++callJingleGeneration;
stopSound(callJingle);
callJingle = null;
const jingle = await requireRuntime(useCall.getState().runtime).load(
"settings.call_jingle",
);
if (generation !== callJingleGeneration || !shouldPlay()) {
return;
}
callJingle = playSound(
jingle === "jingle_2" ? "call_jingle_2" : "call_jingle_1",
true,
);
}
function stopCallJingle() {
callJingleGeneration += 1;
stopSound(callJingle);
callJingle = null;
}
function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> { function protocolBytes(bytes: Uint8Array): Uint8Array<ArrayBuffer> {
return new Uint8Array(bytes); return new Uint8Array(bytes);
} }
@ -273,11 +304,21 @@ function matchesRemoteTrackSelector(
} }
function syncRemoteParticipantTrackSubscriptions(participantId: number) { function syncRemoteParticipantTrackSubscriptions(participantId: number) {
const state = useCall.getState();
const watchesScreen =
state.watchedStreamParticipantIds.includes(participantId);
const cameraDisabled =
state.disabledCameraParticipantIds.includes(participantId);
for (const publication of getRemoteTrackPublications(participantId)) { for (const publication of getRemoteTrackPublications(participantId)) {
publication.setSubscribed( const subscribed =
publication.kind === Track.Kind.Audio && publication.source === Track.Source.Camera
publication.source !== Track.Source.ScreenShareAudio, ? !cameraDisabled
); : publication.source === Track.Source.ScreenShare ||
publication.source === Track.Source.ScreenShareAudio
? watchesScreen
: publication.kind === Track.Kind.Audio;
publication.setSubscribed(subscribed);
} }
} }
@ -567,11 +608,13 @@ export function getRoomMetadata() {
// Sync local participant flags and screen-share derived state for the active call UI. // Sync local participant flags and screen-share derived state for the active call UI.
export function syncParticipantState() { export function syncParticipantState() {
const { screenShareSession } = useCall.getState(); const { cameraSession, screenShareSession } = useCall.getState();
const room = getRoom(); const room = getRoom();
useCall.setState({ useCall.setState({
micEnabled: room.localParticipant.isMicrophoneEnabled, micEnabled: room.localParticipant.isMicrophoneEnabled,
cameraEnabled:
cameraSession != null || room.localParticipant.isCameraEnabled,
screenShareEnabled: screenShareEnabled:
screenShareSession != null || room.localParticipant.isScreenShareEnabled, screenShareSession != null || room.localParticipant.isScreenShareEnabled,
isEncrypted: isEncrypted:
@ -695,6 +738,16 @@ export async function sendCallInvite(userId: number) {
// Start tracking a participant's shared screen in the call UI. // Start tracking a participant's shared screen in the call UI.
export function startWatchingStream(participantId: number) { export function startWatchingStream(participantId: number) {
const trackReady = getScreenShareTrackForParticipant(participantId) != null; const trackReady = getScreenShareTrackForParticipant(participantId) != null;
const alreadyWatching = useCall
.getState()
.watchedStreamParticipantIds.includes(participantId);
const localParticipantId = getParticipantId(
getRoom().localParticipant.identity,
);
if (!alreadyWatching && participantId !== localParticipantId) {
playSound("stream_watch_start");
}
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare); setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare);
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShareAudio); setParticipantTrackSubscribed(participantId, Track.Source.ScreenShareAudio);
@ -730,6 +783,20 @@ export function setParticipantTrackSubscribed(
syncParticipantState(); syncParticipantState();
} }
export function setParticipantCameraDisabled(
participantId: number,
disabled: boolean,
) {
useCall.setState((state) => ({
disabledCameraParticipantIds: disabled
? state.disabledCameraParticipantIds.includes(participantId)
? state.disabledCameraParticipantIds
: [...state.disabledCameraParticipantIds, participantId]
: state.disabledCameraParticipantIds.filter((id) => id !== participantId),
}));
setParticipantTrackSubscribed(participantId, Track.Source.Camera, !disabled);
}
// Focus a participant in the main call view even when they are not sharing a screen. // Focus a participant in the main call view even when they are not sharing a screen.
export function focusParticipant( export function focusParticipant(
participantId: number, participantId: number,
@ -745,6 +812,17 @@ export function focusParticipant(
// Stop tracking a participant's shared screen and clean up related UI state. // Stop tracking a participant's shared screen and clean up related UI state.
export function stopWatchingStream(participantId: number) { export function stopWatchingStream(participantId: number) {
const wasWatching = useCall
.getState()
.watchedStreamParticipantIds.includes(participantId);
const localParticipantId = getParticipantId(
getRoom().localParticipant.identity,
);
if (wasWatching && participantId !== localParticipantId) {
playSound("stream_watch_end");
}
setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare, false); setParticipantTrackSubscribed(participantId, Track.Source.ScreenShare, false);
setParticipantTrackSubscribed( setParticipantTrackSubscribed(
participantId, participantId,
@ -785,9 +863,8 @@ export function stopWatchingFocusedStream() {
stopWatchingStream(focusedParticipantId); stopWatchingStream(focusedParticipantId);
} }
let screenShareController: ReturnType< let mediaShareController: ReturnType<typeof createMediaShareController> | null =
typeof createScreenShareController null;
> | null = null;
function getNoiseFilterAssetBaseUrl() { function getNoiseFilterAssetBaseUrl() {
if (window.location.protocol === "file:") { if (window.location.protocol === "file:") {
@ -797,17 +874,21 @@ function getNoiseFilterAssetBaseUrl() {
return "/assets"; return "/assets";
} }
function getScreenShareController() { function getMediaShareController() {
if (!screenShareController) { if (!mediaShareController) {
screenShareController = createScreenShareController({ mediaShareController = createMediaShareController({
room: getRoom(), room: getRoom(),
getState: () => ({ getState: () => ({
screenShareSession: useCall.getState().screenShareSession, screenShareSession: useCall.getState().screenShareSession,
cameraSession: useCall.getState().cameraSession,
}), }),
setState: (updater) => { setState: (updater) => {
useCall.setState((state) => useCall.setState((state) =>
typeof updater === "function" typeof updater === "function"
? updater({ screenShareSession: state.screenShareSession }) ? updater({
screenShareSession: state.screenShareSession,
cameraSession: state.cameraSession,
})
: updater, : updater,
); );
}, },
@ -819,7 +900,7 @@ function getScreenShareController() {
}); });
} }
return screenShareController; return mediaShareController;
} }
// Connect to LiveKit, enable the microphone, and move the UI into the live call. // Connect to LiveKit, enable the microphone, and move the UI into the live call.
@ -866,11 +947,12 @@ export async function connect(callId: string) {
// Tear down the active call session and return the store to a closed state. // Tear down the active call session and return the store to a closed state.
export async function disconnect() { export async function disconnect() {
stopCallJingle();
disposeSpeakingDetector(); disposeSpeakingDetector();
await clearScreenSharePreview(); await clearScreenSharePreview();
try { try {
await getScreenShareController().clearPublishedScreenShare(); await getMediaShareController().clearAll();
} catch (error) { } catch (error) {
log( log(
1, 1,
@ -892,6 +974,9 @@ export async function disconnect() {
deaf: false, deaf: false,
view: "preview", view: "preview",
screenShareSession: null, screenShareSession: null,
cameraSession: null,
cameraEnabled: false,
disabledCameraParticipantIds: [],
focusedParticipantId: null, focusedParticipantId: null,
focusedParticipantType: null, focusedParticipantType: null,
usersInFocusedViewHidden: false, usersInFocusedViewHidden: false,
@ -939,6 +1024,10 @@ export async function joinCall(
ownCallSecretInvitePending: isNewCall, ownCallSecretInvitePending: isNewCall,
}); });
if (sendInvite && !existingCallId) {
void startCallJingle(() => useCall.getState().invitedUserId != null);
}
if (callSecret) { if (callSecret) {
try { try {
if (!existingCallId) { if (!existingCallId) {
@ -1015,37 +1104,37 @@ export async function toggleMute() {
syncParticipantState(); syncParticipantState();
} }
// Start browser-native screen sharing for the current participant. export async function startScreenShare(
export async function startScreenShare(options?: ScreenShareCaptureOptions) { request: Omit<MediaShareRequest, "kind"> = {},
await getScreenShareController().startScreenShare(options); ) {
await getMediaShareController().start({ ...request, kind: "screen" });
await publishScreenSharePreview(); await publishScreenSharePreview();
} }
// Start the Linux desktop capture path that renders frames through Tauri. export async function startCameraShare(sourceId?: string) {
export async function startLinuxDesktopScreenShare(sourceId: string) { await getMediaShareController().start({ kind: "camera", sourceId });
await getScreenShareController().startLinuxDesktopScreenShare(sourceId);
await publishScreenSharePreview();
} }
// Stop the local participant's active screen share and related previews. // Stop the local participant's active screen share and related previews.
export async function stopScreenShare() { export async function stopScreenShare() {
await getScreenShareController().stopScreenShare(); await getMediaShareController().stop("screen");
await clearScreenSharePreview(); await clearScreenSharePreview();
} }
export async function stopCameraShare() {
await getMediaShareController().stop("camera");
}
// Toggle screen sharing on or off from UI controls. // Toggle screen sharing on or off from UI controls.
export async function setScreenShareEnabled( export async function setScreenShareEnabled(
enabled: boolean, enabled: boolean,
options?: ScreenShareCaptureOptions, request: Omit<MediaShareRequest, "kind"> = {},
) { ) {
await getScreenShareController().setScreenShareEnabled(enabled, options);
if (enabled) { if (enabled) {
await publishScreenSharePreview(); await startScreenShare(request);
return; return;
} }
await stopScreenShare();
await clearScreenSharePreview();
} }
// Reset the in-memory call store when leaving the call experience entirely. // Reset the in-memory call store when leaving the call experience entirely.
@ -1060,7 +1149,10 @@ export function resetCallState() {
livekitToken: null, livekitToken: null,
currentCallData: null, currentCallData: null,
deaf: false, deaf: false,
cameraEnabled: false,
screenShareSession: null, screenShareSession: null,
cameraSession: null,
disabledCameraParticipantIds: [],
focusedParticipantId: null, focusedParticipantId: null,
focusedParticipantType: null, focusedParticipantType: null,
usersInFocusedViewHidden: false, usersInFocusedViewHidden: false,
@ -1114,8 +1206,11 @@ export const useCall = create<CallStore>(() => ({
currentCallData: null, currentCallData: null,
deaf: false, deaf: false,
micEnabled: false, micEnabled: false,
cameraEnabled: false,
screenShareEnabled: false, screenShareEnabled: false,
screenShareSession: null, screenShareSession: null,
cameraSession: null,
disabledCameraParticipantIds: [],
focusedParticipantId: null, focusedParticipantId: null,
focusedParticipantType: null, focusedParticipantType: null,
usersInFocusedViewHidden: false, usersInFocusedViewHidden: false,
@ -1174,12 +1269,14 @@ export function useInitializeCall() {
useCall.setState({ useCall.setState({
incomingCallInvite: { callId, callSecret, senderId }, incomingCallInvite: { callId, callSecret, senderId },
}); });
void startCallJingle(() => useCall.getState().incomingCallInvite != null);
}, },
[], [],
); );
const setInvitePopupOpen = useCallback((open: boolean) => { const setInvitePopupOpen = useCallback((open: boolean) => {
if (!open) { if (!open) {
stopCallJingle();
useCall.setState({ incomingCallInvite: null }); useCall.setState({ incomingCallInvite: null });
} }
}, []); }, []);
@ -1188,6 +1285,7 @@ export function useInitializeCall() {
(accepted: boolean) => { (accepted: boolean) => {
const invite = useCall.getState().incomingCallInvite; const invite = useCall.getState().incomingCallInvite;
stopCallJingle();
useCall.setState({ incomingCallInvite: null }); useCall.setState({ incomingCallInvite: null });
if (!invite) { if (!invite) {
@ -1235,6 +1333,11 @@ export function useInitializeCall() {
return; return;
} }
const currentCall = useCall.getState();
if (currentCall.callId === CallId && currentCall.state !== "closed") {
return;
}
showCallingScreen( showCallingScreen(
CallId, CallId,
normalizeWrappedCallSecret(CallSecret), normalizeWrappedCallSecret(CallSecret),
@ -1303,6 +1406,7 @@ export function useInitializeCall() {
const onConnected = async () => { const onConnected = async () => {
useCall.setState({ state: "open" }); useCall.setState({ state: "open" });
playSound("call_join");
syncParticipantState(); syncParticipantState();
const detector = getSpeakingDetector(); const detector = getSpeakingDetector();
@ -1376,6 +1480,8 @@ export function useInitializeCall() {
}; };
const onDisconnected = () => { const onDisconnected = () => {
stopCallJingle();
playSound("call_leave");
useCall.setState({ state: "closed" }); useCall.setState({ state: "closed" });
syncParticipantState(); syncParticipantState();
log(2, "call", "purple", "Disconnected from call", { log(2, "call", "purple", "Disconnected from call", {
@ -1385,11 +1491,14 @@ export function useInitializeCall() {
}; };
const onParticipantConnected = () => { const onParticipantConnected = () => {
stopCallJingle();
playSound("call_join");
syncAllRemoteTrackSubscriptions(); syncAllRemoteTrackSubscriptions();
syncParticipantState(); syncParticipantState();
}; };
const onParticipantDisconnected = (participant: Participant) => { const onParticipantDisconnected = (participant: Participant) => {
playSound("call_leave");
const participantId = getParticipantId(participant.identity); const participantId = getParticipantId(participant.identity);
if (participantId != null) { if (participantId != null) {
@ -1416,6 +1525,10 @@ export function useInitializeCall() {
}; };
const onLocalTrackPublished = (publication: LocalTrackPublication) => { const onLocalTrackPublished = (publication: LocalTrackPublication) => {
if (publication.source === Track.Source.ScreenShare) {
playSound("stream_start_self");
}
if ( if (
publication.kind === Track.Kind.Audio && publication.kind === Track.Kind.Audio &&
publication.source === Track.Source.Microphone && publication.source === Track.Source.Microphone &&
@ -1437,6 +1550,10 @@ export function useInitializeCall() {
}; };
const onLocalTrackUnpublished = (publication: LocalTrackPublication) => { const onLocalTrackUnpublished = (publication: LocalTrackPublication) => {
if (publication.source === Track.Source.ScreenShare) {
playSound("stream_end_self");
}
if ( if (
publication.kind === Track.Kind.Audio && publication.kind === Track.Kind.Audio &&
publication.source === Track.Source.Microphone publication.source === Track.Source.Microphone
@ -1453,17 +1570,22 @@ export function useInitializeCall() {
publication: RemoteTrackPublication, publication: RemoteTrackPublication,
participant: RemoteParticipant, participant: RemoteParticipant,
) => { ) => {
if (publication.source === Track.Source.ScreenShare) {
playSound("stream_start_other");
}
const participantId = getParticipantId(participant.identity); const participantId = getParticipantId(participant.identity);
if (participantId != null) { if (participantId != null) {
if ( syncRemoteParticipantTrackSubscriptions(participantId);
publication.kind === Track.Kind.Audio && }
publication.source !== Track.Source.ScreenShareAudio
) { onParticipantStateChange();
publication.setSubscribed(true); };
} else {
publication.setSubscribed(false); const onTrackUnpublished = (publication: RemoteTrackPublication) => {
} if (publication.source === Track.Source.ScreenShare) {
playSound("stream_end_other");
} }
onParticipantStateChange(); onParticipantStateChange();
@ -1526,7 +1648,7 @@ export function useInitializeCall() {
room.on(RoomEvent.TrackSubscribed, onTrackSubscribed); room.on(RoomEvent.TrackSubscribed, onTrackSubscribed);
room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed); room.on(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
room.on(RoomEvent.TrackPublished, onTrackPublished); room.on(RoomEvent.TrackPublished, onTrackPublished);
room.on(RoomEvent.TrackUnpublished, onParticipantStateChange); room.on(RoomEvent.TrackUnpublished, onTrackUnpublished);
room.on(RoomEvent.ParticipantConnected, onParticipantConnected); room.on(RoomEvent.ParticipantConnected, onParticipantConnected);
room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected); room.on(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
room.on(RoomEvent.TrackMuted, onParticipantStateChange); room.on(RoomEvent.TrackMuted, onParticipantStateChange);
@ -1547,7 +1669,7 @@ export function useInitializeCall() {
room.off(RoomEvent.TrackSubscribed, onTrackSubscribed); room.off(RoomEvent.TrackSubscribed, onTrackSubscribed);
room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed); room.off(RoomEvent.TrackUnsubscribed, onTrackUnsubscribed);
room.off(RoomEvent.TrackPublished, onTrackPublished); room.off(RoomEvent.TrackPublished, onTrackPublished);
room.off(RoomEvent.TrackUnpublished, onParticipantStateChange); room.off(RoomEvent.TrackUnpublished, onTrackUnpublished);
room.off(RoomEvent.ParticipantConnected, onParticipantConnected); room.off(RoomEvent.ParticipantConnected, onParticipantConnected);
room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected); room.off(RoomEvent.ParticipantDisconnected, onParticipantDisconnected);
room.off(RoomEvent.TrackMuted, onParticipantStateChange); room.off(RoomEvent.TrackMuted, onParticipantStateChange);
@ -1585,7 +1707,7 @@ export function useInitializeCall() {
} }
setCurrentCallData({ setCurrentCallData({
...(data.data as z.infer<typeof mtp.CallData.response>), ...mtp.CallData.response.parse(data.data),
exists: true, exists: true,
}); });
}) })

View file

@ -1,12 +1,14 @@
import { RoomEvent } from "livekit-client"; import { RoomEvent } from "livekit-client";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react"; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { useCall, getRoom } from "../../store"; import { getRoom, setUsersInFocusedViewHidden, useCall } from "../../store";
import Base from "../../components/modals/base"; import Base from "../../components/modals/base";
import { useIsMobile } from "@methanium/ui";
const SECONDARY_ROW_HEIGHT_PX = 180; const SECONDARY_ROW_HEIGHT_PX = 180;
const STACK_GAP_PX = 12; const STACK_GAP_PX = 12;
export default function View() { export default function View() {
const isMobile = useIsMobile();
const room = getRoom(); const room = getRoom();
const layoutVersion = useCall((state) => state.layoutVersion); const layoutVersion = useCall((state) => state.layoutVersion);
@ -27,6 +29,10 @@ export default function View() {
const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false); const [isFocusedTileFlush, setIsFocusedTileFlush] = useState(false);
const [participantVersion, setParticipantVersion] = useState(0); const [participantVersion, setParticipantVersion] = useState(0);
useEffect(() => {
if (isMobile) setUsersInFocusedViewHidden(true);
}, [isMobile]);
useEffect(() => { useEffect(() => {
const syncParticipants = () => { const syncParticipants = () => {
setParticipantVersion((version) => version + 1); setParticipantVersion((version) => version + 1);

View file

@ -2,6 +2,7 @@ import { RoomEvent } from "livekit-client";
import { useEffect, useMemo, useRef, useState } from "react"; import { useEffect, useMemo, useRef, useState } from "react";
import { useCall, getRoom } from "../../store"; import { useCall, getRoom } from "../../store";
import Base from "../../components/modals/base"; import Base from "../../components/modals/base";
import { cn, useIsMobile } from "@methanium/ui";
const TILE_ASPECT_RATIO = 16 / 9; const TILE_ASPECT_RATIO = 16 / 9;
const GRID_GAP = 12; const GRID_GAP = 12;
@ -214,6 +215,8 @@ export default function View() {
return room.getParticipantByIdentity(String(participantId)); return room.getParticipantByIdentity(String(participantId));
} }
const isMobile = useIsMobile();
return ( return (
<div <div
ref={containerRef} ref={containerRef}
@ -221,7 +224,7 @@ export default function View() {
height: "calc(100% - 2rem)", height: "calc(100% - 2rem)",
width: "calc(100% - 2rem)", width: "calc(100% - 2rem)",
}} }}
className="overflow-hidden p-3" className={cn("overflow-hidden", isMobile ? "" : "p-3")}
> >
<div className="flex h-full w-full flex-col items-center justify-center gap-3"> <div className="flex h-full w-full flex-col items-center justify-center gap-3">
{rows.map((row) => ( {rows.map((row) => (

View file

@ -7,6 +7,7 @@ import {
triggerCallLayoutCalculation, triggerCallLayoutCalculation,
useCall, useCall,
} from "../../store"; } from "../../store";
import { useIsMobile } from "@methanium/ui";
export default function Layout({ children }: { children: React.ReactNode }) { export default function Layout({ children }: { children: React.ReactNode }) {
const screenRef = useRef<HTMLDivElement>(null); const screenRef = useRef<HTMLDivElement>(null);
@ -129,6 +130,8 @@ export default function Layout({ children }: { children: React.ReactNode }) {
setIsImmersiveChromeVisible(false); setIsImmersiveChromeVisible(false);
}; };
const isMobile = useIsMobile();
return ( return (
<div <div
ref={screenRef} ref={screenRef}
@ -139,23 +142,25 @@ export default function Layout({ children }: { children: React.ReactNode }) {
onMouseMove={showImmersiveChrome} onMouseMove={showImmersiveChrome}
onMouseLeave={hideImmersiveChrome} onMouseLeave={hideImmersiveChrome}
> >
<div {!isMobile && (
ref={topBarRef} <div
className={`shrink-0 w-full z-40 transition-all duration-200 ${ ref={topBarRef}
isImmersiveFocusedView className={`shrink-0 w-full z-40 transition-all duration-200 ${
? immersiveChromeVisible isImmersiveFocusedView
? "opacity-100 translate-y-0 pointer-events-auto" ? immersiveChromeVisible
: "opacity-0 -translate-y-2 pointer-events-none" ? "opacity-100 translate-y-0 pointer-events-auto"
: "opacity-100 translate-y-0" : "opacity-0 -translate-y-2 pointer-events-none"
}`} : "opacity-100 translate-y-0"
style={{ }`}
position: usersInFocusedViewHidden ? "absolute" : "relative", style={{
top: 0, position: usersInFocusedViewHidden ? "absolute" : "relative",
left: 0, top: 0,
}} left: 0,
> }}
<TopBar /> >
</div> <TopBar />
</div>
)}
<div <div
className="min-h-0 flex-1 w-full flex justify-center items-center overflow-hidden" className="min-h-0 flex-1 w-full flex justify-center items-center overflow-hidden"
style={ style={

View file

@ -1,6 +1,5 @@
- Overlay for stream modals - Overlay for stream modals
- Mobile - Mobile
- Sounds
- Admin call actions - Admin call actions
- Timeout - Timeout
- Disconnect - Disconnect
@ -8,3 +7,4 @@
- Add quality selection - Add quality selection
- Good preview page - Good preview page
- Settings page - Settings page
- Make the Three-Dots button open a dialog/context-menu to configure the call for anonymous invites or other admin stuff

View file

@ -26,7 +26,7 @@
"@tensamin/mtp": "workspace:*", "@tensamin/mtp": "workspace:*",
"@tensamin/shared": "workspace:*", "@tensamin/shared": "workspace:*",
"@tensamin/storage": "workspace:*", "@tensamin/storage": "workspace:*",
"@tensamin/ui": "*", "@methanium/ui": "*",
"@tensamin/user": "workspace:*", "@tensamin/user": "workspace:*",
"lucide-react": "^1.14.0", "lucide-react": "^1.14.0",
"motion": "^12.42.2", "motion": "^12.42.2",

View file

@ -1,4 +1,4 @@
import { Button } from "@tensamin/ui"; import { Button } from "@methanium/ui";
import Emoji from "@tensamin/markdown/emoji"; import Emoji from "@tensamin/markdown/emoji";
import { getRecentEmojis, useEmojiRanks } from "./emojiRanks"; import { getRecentEmojis, useEmojiRanks } from "./emojiRanks";

View file

@ -7,7 +7,7 @@ import {
TabsContent, TabsContent,
TabsList, TabsList,
TabsTrigger, TabsTrigger,
} from "@tensamin/ui"; } from "@methanium/ui";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { Loader2, Search } from "lucide-react"; import { Loader2, Search } from "lucide-react";
import MediaSaveButton from "./mediaSaveButton"; import MediaSaveButton from "./mediaSaveButton";

View file

@ -1,32 +1,27 @@
import Input from "@tensamin/markdown/input"; import Input from "@tensamin/markdown/input";
import { import {
Avatar,
AvatarFallback,
AvatarImage,
Card, Card,
CardHeader, CardHeader,
Popover, Popover,
PopoverContent, PopoverContent,
PopoverTrigger, PopoverTrigger,
Skeleton, } from "@methanium/ui";
} from "@tensamin/ui";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import React, { useEffect, useState, useRef } from "react"; import React, { useEffect, useState, useRef } from "react";
import { Button } from "@tensamin/ui"; import { Button } from "@methanium/ui";
import { Plus, Laugh, FileVideo, Forward, X } from "lucide-react"; import { Plus, Laugh, FileVideo } from "lucide-react";
import { useChat, useReplyMessage } from "../context"; import { useChat, useReplyMessage } from "../context";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import { log, toast } from "@tensamin/shared/log"; import { log, toast } from "@tensamin/shared/log";
import { cn, useIsMobile } from "@tensamin/ui"; import { cn, useIsMobile } from "@methanium/ui";
import { encryptChatText } from "@tensamin/crypto/chatSecret"; import { encryptChatText } from "@tensamin/crypto/chatSecret";
import { useSession } from "@tensamin/storage/session"; import { useSession } from "@tensamin/storage/session";
import GifPicker from "./gifPicker"; import GifPicker from "./gifPicker";
import EmojiPicker from "./emojiPicker"; import EmojiPicker from "./emojiPicker";
import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks"; import { useEmojiRanks, useRecordEmojiUse } from "./emojiRanks";
import Wrapper from "@tensamin/user/wrapper"; import ReplyBox from "./replyBox";
import Text from "@tensamin/markdown/text";
export default function InputComponent({ export default function InputComponent({
value, value,
@ -117,6 +112,7 @@ export default function InputComponent({
Content: encryptedContent, Content: encryptedContent,
ReceiverId: userId, ReceiverId: userId,
SendTime: time, SendTime: time,
...(replyTo && { ReplyId: replyTo }),
}).catch((e) => { }).catch((e) => {
log(0, "Chat", "red", "Failed to send message", e, { log(0, "Chat", "red", "Failed to send message", e, {
ReceiverId: userId, ReceiverId: userId,
@ -196,54 +192,15 @@ export default function InputComponent({
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
{/* reply */}
{replyTo !== undefined && ( {replyTo !== undefined && (
<div className="self-center rounded-t-lg bg-background border-t border-x flex items-center justify-start w-[90%] p-1"> <ReplyBox
<div className="flex gap-1 w-full items-center"> content={replyMessage?.Content}
<Forward loading={!replyMessage}
color="var(--muted-foreground)" onDismiss={() => setReplyTo(undefined)}
className="opacity-60" userId={replyUserId ?? ownId}
size={18} variant="composer"
/> />
{replyMessage ? (
<>
<Wrapper
userId={replyUserId ?? ownId}
loading={<Skeleton className="rounded-full" />}
component={(user) => (
<Avatar>
<AvatarImage src={user.Avatar} />
<AvatarFallback>
{user.Display.slice(2, 0).toUpperCase()}
</AvatarFallback>
</Avatar>
)}
/>
<div className="max-w-full max-h-5 h-5 text-xs truncate">
<Text value={replyMessage.Content} />
</div>
</>
) : (
<>
<Skeleton className="w-5 h-5 aspect-square rounded-full" />
<Skeleton className="w-full h-5" />
</>
)}
<Button
onClick={() => {
setReplyTo(undefined);
}}
className="w-5 h-5 rounded-md! p-0!"
size="xs"
variant="ghost"
>
<X
color="var(--muted-foreground)"
className="opacity-60"
size={18}
/>
</Button>
</div>
</div>
)} )}
<Card <Card
ref={inputBoxRef} ref={inputBoxRef}
@ -267,7 +224,7 @@ export default function InputComponent({
/> />
<div className="w-full flex justify-between gap-1 p-1 pt-0"> <div className="w-full flex justify-between gap-1 p-1 pt-0">
<div className="flex gap-1"> <div className="flex gap-1">
<Button className="w-9 h-9 p-0" variant="ghost"> <Button className="w-9 h-9! p-0" variant="ghost">
<Plus size={20} /> <Plus size={20} />
</Button> </Button>
<div className="w-auto flex text-red-500"> <div className="w-auto flex text-red-500">
@ -280,15 +237,16 @@ export default function InputComponent({
onOpenChange={setEmojiPopoverOpen} onOpenChange={setEmojiPopoverOpen}
> >
<PopoverTrigger <PopoverTrigger
render={ render={({ onClick }) => (
<Button <Button
onClick={onClick}
aria-label="Open emoji picker" aria-label="Open emoji picker"
className="w-9 h-9 p-0" className="w-9 h-9! p-0"
variant="ghost" variant="ghost"
> >
<Laugh size={20} /> <Laugh size={20} />
</Button> </Button>
} )}
/> />
<PopoverContent className="w-auto p-0"> <PopoverContent className="w-auto p-0">
<EmojiPicker <EmojiPicker
@ -302,11 +260,15 @@ export default function InputComponent({
</Popover> </Popover>
<Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}> <Popover open={gifPopoverOpen} onOpenChange={setGifPopoverOpen}>
<PopoverTrigger <PopoverTrigger
render={ render={({ onClick }) => (
<Button className="w-9 h-9 p-0" variant="ghost"> <Button
onClick={onClick}
className="w-9 h-9! p-0"
variant="ghost"
>
<FileVideo size={20} /> <FileVideo size={20} />
</Button> </Button>
} )}
/> />
<PopoverContent <PopoverContent
ref={gifPopoverRef} ref={gifPopoverRef}

View file

@ -1,6 +1,6 @@
import Text from "@tensamin/markdown/text"; import Text from "@tensamin/markdown/text";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { Tooltip, TooltipContent, TooltipTrigger } from "@tensamin/ui"; import { Tooltip, TooltipContent, TooltipTrigger } from "@methanium/ui";
import { TriangleAlert } from "lucide-react"; import { TriangleAlert } from "lucide-react";
import { useState, useMemo, useEffect } from "react"; import { useState, useMemo, useEffect } from "react";
import MediaSaveButton from "./mediaSaveButton"; import MediaSaveButton from "./mediaSaveButton";

View file

@ -1,4 +1,4 @@
import { Button, cn } from "@tensamin/ui"; import { Button, cn } from "@methanium/ui";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { Save } from "lucide-react"; import { Save } from "lucide-react";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";

View file

@ -1,8 +1,7 @@
import * as React from "react";
import type { RawMessage } from "../values"; import type { RawMessage } from "../values";
import Text from "@tensamin/markdown/text"; import Text from "@tensamin/markdown/text";
import { AlertTriangle, Check, Ellipse, RefreshCw } from "lucide-react"; import { AlertTriangle, Check, CheckLine, RefreshCw } from "lucide-react";
import { useCallback, useEffect, useState } from "react"; import { memo, useCallback, useEffect, useState } from "react";
import type { User } from "@tensamin/user/context"; import type { User } from "@tensamin/user/context";
import { import {
@ -12,17 +11,19 @@ import {
Button, Button,
cn, cn,
Skeleton, Skeleton,
} from "@tensamin/ui"; } from "@methanium/ui";
import MessageContextMenu from "./messageContextMenu"; import MessageContextMenu from "./messageContextMenu";
import Media from "./media"; import Media from "./media";
import { useStorage } from "@tensamin/storage/context"; import { useStorage } from "@tensamin/storage/context";
import { useMTP } from "@tensamin/mtp"; import { useMTP } from "@tensamin/mtp";
import Input from "@tensamin/markdown/input"; import Input from "@tensamin/markdown/input";
import { useChat } from "../context"; import { getMessage, useChat } from "../context";
import { encryptChatText } from "@tensamin/crypto/chatSecret"; import { decryptChatText, encryptChatText } from "@tensamin/crypto/chatSecret";
import { log, toast } from "@tensamin/shared/log"; import { log, toast } from "@tensamin/shared/log";
import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji"; import Emoji, { normalizeShortcode } from "@tensamin/markdown/emoji";
import { useRecordEmojiUse } from "./emojiRanks"; import { useRecordEmojiUse } from "./emojiRanks";
import { useUser } from "@tensamin/user/context";
import ReplyBox from "./replyBox";
function MessageComponent({ function MessageComponent({
grouped, grouped,
@ -142,7 +143,45 @@ function MessageComponent({
userId, userId,
deleteMessage, deleteMessage,
removeReaction, removeReaction,
replyTo,
} = useChat(); } = useChat();
const { get: getUser } = useUser();
const [replyMessage, setReplyMessage] = useState<RawMessage | null>(null);
const [replyUser, setReplyUser] = useState<User | null>(null);
useEffect(() => {
if (!message.ReplyId || !ownId || !chatSecret) {
setReplyMessage(null);
setReplyUser(null);
return;
}
let active = true;
void getMessage({
sendTime: message.ReplyId,
ownId,
chatPartnerId: userId,
send,
})
.then(async (reply) => {
const [Content, author] = await Promise.all([
decryptChatText(chatSecret, reply.Content),
getUser(reply.SenderId),
]);
if (!active) return;
setReplyMessage({ ...reply, Content });
setReplyUser(author);
})
.catch((err) => {
if (!active) return;
setReplyMessage(null);
setReplyUser(null);
log(1, "chat", "red", "Failed to get replied-to message", err);
});
return () => {
active = false;
};
}, [chatSecret, getUser, message.ReplyId, ownId, send, userId]);
const recordUse = useRecordEmojiUse(); const recordUse = useRecordEmojiUse();
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editDraft, setEditDraft] = useState(message.Content); const [editDraft, setEditDraft] = useState(message.Content);
@ -233,8 +272,16 @@ function MessageComponent({
return ( return (
<div <div
// pt-3 is to get a gap between messages // pt-3 is to get a gap between messages
className={`${grouped ? "" : "pt-3"} w-full flex justify-start transition-opacity duration-150 ${opacityClass}`} className={`${grouped ? "" : "pt-3"} w-full flex flex-col gap-1 justify-start items-start transition-opacity duration-150 ${opacityClass}`}
> >
{/* reply */}
{replyMessage && replyUser && (
<ReplyBox
content={replyMessage.Content}
user={replyUser}
variant="message"
/>
)}
{user && message.Content ? ( {user && message.Content ? (
<MessageContextMenu <MessageContextMenu
content={message.Content} content={message.Content}
@ -244,138 +291,149 @@ function MessageComponent({
onReact={toggleReaction} onReact={toggleReaction}
onSetEditing={setEditing} onSetEditing={setEditing}
> >
<div <>
className={cn( <div
"group hover:bg-muted/50 select-text! relative justify-start flex gap-1 items-center w-full px-2 whitespace-pre-wrap break-all", className={cn(
{ "group hover:bg-muted/50 select-text! relative justify-start flex gap-1 items-center w-full px-2 whitespace-pre-wrap break-all transition-colors duration-200 ease-in-out",
"bg-(--destructive)/10 text-destructive-foreground hover:bg-(--destructive)/15": {
actuallyFailed, "bg-(--destructive)/10 text-destructive-foreground hover:bg-(--destructive)/15":
}, actuallyFailed,
)} "bg-(--primary-foreground-alt)/15 hover:bg-(--primary-foreground-alt)/20":
> replyTo === message.SendTime && !actuallyFailed,
<> },
{grouped ? (
<p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
{new Date(message.SendTime).toLocaleString([], {
hour: "2-digit",
minute: "2-digit",
})}
</p>
) : (
<Avatar className="mr-1 mb-auto mt-1 w-10">
<AvatarImage src={user.Avatar} />
<AvatarFallback>
{user.Display.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
)} )}
<div >
className={cn("flex flex-col group", { <>
"min-w-0 flex-1": editing, {grouped ? (
})} <p className="w-9 text-xs group-hover:visible invisible text-muted-foreground">
> {new Date(message.SendTime).toLocaleString([], {
{!grouped && ( hour: "2-digit",
<div className="flex items-center gap-1"> minute: "2-digit",
<p className="font-medium">{user.Display}</p> })}
<p className="text-xs text-muted-foreground"> </p>
{new Date(message.SendTime).toLocaleString([], { ) : (
hour: "2-digit", <Avatar className="mr-1 mb-auto mt-1 w-10">
minute: "2-digit", <AvatarImage src={user.Avatar} />
})} <AvatarFallback>
</p> {user.Display.slice(0, 2).toUpperCase()}
<div className="flex items-center justify-start gap-1"> </AvatarFallback>
{message.MessageState === "read" ? ( </Avatar>
<Check )}
size={12} <div
color="var(--primary-foreground-alt)" className={cn("flex flex-col group", {
/> "min-w-0 flex-1": editing,
) : message.MessageState === "received" ? ( })}
<Check size={12} color="var(--muted-foreground)" /> >
) : message.MessageState === "sent" ? ( {!grouped && (
<RefreshCw size={12} color="var(--muted-foreground)" /> <div className="flex items-center gap-1">
) : message.MessageState === "sending" ? ( <p className="font-semibold">{user.Display}</p>
<Ellipse size={12} color="var(--muted-foreground)" /> <p className="text-xs text-muted-foreground">
) : null} {new Date(message.SendTime).toLocaleString([], {
{actuallyFailed && ( hour: "2-digit",
<AlertTriangle color="var(--destructive)" size={12} /> minute: "2-digit",
})}
</p>
<div className="flex items-center justify-start gap-1">
{message.SenderId !==
ownId ? null : message.MessageState === "read" ? (
<Check
size={12}
color="var(--primary-foreground-alt)"
/>
) : message.MessageState === "received" ? (
<CheckLine
size={12}
color="var(--muted-foreground)"
/>
) : message.MessageState === "sent" ? (
<Check size={12} color="var(--muted-foreground)" />
) : message.MessageState === "sending" ? (
<RefreshCw
size={12}
color="var(--muted-foreground)"
/>
) : null}
{actuallyFailed && (
<AlertTriangle color="var(--destructive)" size={12} />
)}
</div>
</div>
)}
{editing ? (
<div className="flex w-full flex-col gap-1">
<Input
className="w-full"
styled
setValue={setEditDraft}
value={editDraft}
onSubmit={() => {
submitEditMessage(editDraft);
setEditing(false);
}}
/>
<div className="flex gap-1">
<Button
size="xs"
variant="link"
className="text-primary-foreground-alt"
onClick={() => {
if (editDraft === message.Content) {
deleteMessage(message.SendTime);
} else {
submitEditMessage(editDraft);
}
setEditDraft(message.Content);
setEditing(false);
}}
>
Save
</Button>
<Button
size="xs"
variant="link"
className="text-muted-foreground"
onClick={() => {
setEditDraft(message.Content);
setEditing(false);
}}
>
Cancel
</Button>
</div>
</div>
) : isValidURL ? (
<Media link={message.Content} />
) : (
<Text value={message.Content} />
)}
{groupedReactions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1 pb-1">
{groupedReactions.map(
([reaction, { count, reactedByMe }]) => (
<Button
key={reaction}
aria-label={`${reactedByMe ? "Remove" : "Add"} ${reaction} reaction`}
className={cn(
"h-7 gap-2 rounded-lg py-3.5 px-1.5! border",
reactedByMe
? "border-(--primary-foreground-alt)/40!"
: "",
)}
onClick={() => void toggleReaction(reaction)}
size="xs"
variant={reactedByMe ? "subtleDefault" : "outline"}
>
<Emoji className="h-5 w-5" shortcode={reaction} />
<span className="text-sm">{count}</span>
</Button>
),
)} )}
</div> </div>
</div> )}
)} </div>
{editing ? ( </>
<div className="flex w-full flex-col"> </div>
<Input </>
className="w-full"
styled
setValue={setEditDraft}
value={editDraft}
onSubmit={() => {
submitEditMessage(editDraft);
setEditing(false);
}}
/>
<div className="flex">
<Button
size="xs"
variant="link"
className="text-primary-foreground-alt"
onClick={() => {
if (editDraft === message.Content) {
deleteMessage(message.SendTime);
} else {
submitEditMessage(editDraft);
}
setEditDraft(message.Content);
setEditing(false);
}}
>
Save
</Button>
<Button
size="xs"
variant="link"
className="text-muted-foreground"
onClick={() => {
setEditDraft(message.Content);
setEditing(false);
}}
>
Cancel
</Button>
</div>
</div>
) : isValidURL ? (
<Media link={message.Content} />
) : (
<Text value={message.Content} />
)}
{groupedReactions.length > 0 && (
<div className="mt-1 flex flex-wrap gap-1 pb-1">
{groupedReactions.map(
([reaction, { count, reactedByMe }]) => (
<Button
key={reaction}
aria-label={`${reactedByMe ? "Remove" : "Add"} ${reaction} reaction`}
className={cn(
"h-7 gap-2 rounded-lg py-3.5 px-1.5! border",
reactedByMe
? "border-(--primary-foreground-alt)/40!"
: "",
)}
onClick={() => void toggleReaction(reaction)}
size="xs"
variant={reactedByMe ? "subtleDefault" : "outline"}
>
<Emoji className="h-5 w-5" shortcode={reaction} />
<span className="text-sm">{count}</span>
</Button>
),
)}
</div>
)}
</div>
</>
</div>
</MessageContextMenu> </MessageContextMenu>
) : ( ) : (
<Skeleton className="h-10 w-30" /> <Skeleton className="h-10 w-30" />
@ -384,11 +442,12 @@ function MessageComponent({
); );
} }
export default React.memo(MessageComponent, (prev, next) => { export default memo(MessageComponent, (prev, next) => {
return ( return (
prev.message.SendTime === next.message.SendTime && prev.message.SendTime === next.message.SendTime &&
prev.message.Content === next.message.Content && prev.message.Content === next.message.Content &&
prev.message.SenderId === next.message.SenderId && prev.message.SenderId === next.message.SenderId &&
prev.message.ReplyId === next.message.ReplyId &&
prev.message.MessageState === next.message.MessageState && prev.message.MessageState === next.message.MessageState &&
prev.message.failed === next.message.failed && prev.message.failed === next.message.failed &&
prev.message.decryptionFailed === next.message.decryptionFailed && prev.message.decryptionFailed === next.message.decryptionFailed &&

View file

@ -23,7 +23,7 @@ import {
TooltipContent, TooltipContent,
TooltipTrigger, TooltipTrigger,
useIsMobile, useIsMobile,
} from "@tensamin/ui"; } from "@methanium/ui";
import { import {
Clipboard, Clipboard,
Ellipsis, Ellipsis,
@ -35,11 +35,13 @@ import {
Reply, Reply,
Trash, Trash,
} from "lucide-react"; } from "lucide-react";
import { AnimatePresence, motion } from "motion/react";
import { cloneElement, useMemo, useState, useSyncExternalStore } from "react"; import { cloneElement, useMemo, useState, useSyncExternalStore } from "react";
import type { import type {
MouseEvent as ReactMouseEvent, MouseEvent as ReactMouseEvent,
ReactElement, ReactElement,
ReactNode, ReactNode,
Ref,
} from "react"; } from "react";
import { useChat } from "../context"; import { useChat } from "../context";
import Emoji from "@tensamin/markdown/emoji"; import Emoji from "@tensamin/markdown/emoji";
@ -260,18 +262,32 @@ function MiniMenuTooltip({
children, children,
label, label,
}: { }: {
children: ReactElement; children: ReactElement<{
onClick?: (event: ReactMouseEvent<HTMLElement>) => void;
ref?: Ref<HTMLElement>;
}>;
label: string; label: string;
}) { }) {
return ( return (
<Tooltip> <Tooltip>
<TooltipTrigger render={children} /> <TooltipTrigger
render={({ ref, onClick }) =>
cloneElement(children, {
ref,
onClick: (event) => {
onClick?.(event);
children.props.onClick?.(event);
},
})
}
/>
<TooltipContent>{label}</TooltipContent> <TooltipContent>{label}</TooltipContent>
</Tooltip> </Tooltip>
); );
} }
function MiniMessageMenu({ function MiniMessageMenu({
fadeOut,
isOwnMessage, isOwnMessage,
onDelete, onDelete,
onEdit, onEdit,
@ -283,6 +299,7 @@ function MiniMessageMenu({
onReply, onReply,
shiftIsPressed, shiftIsPressed,
}: { }: {
fadeOut: boolean;
isOwnMessage: boolean; isOwnMessage: boolean;
onDelete: () => void; onDelete: () => void;
onEdit: () => void; onEdit: () => void;
@ -295,8 +312,12 @@ function MiniMessageMenu({
shiftIsPressed: boolean; shiftIsPressed: boolean;
}) { }) {
return ( return (
<div className="absolute right-4 -top-3 z-10"> <motion.div
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-0! shadow-lg"> className="absolute right-4 -top-3 z-10"
exit={fadeOut ? { opacity: 0 } : undefined}
transition={fadeOut ? { duration: 0.048 } : undefined}
>
<Card className="flex flex-row justify-end gap-0! rounded-lg! p-px! shadow-lg">
{quickReactions.map((emoji) => ( {quickReactions.map((emoji) => (
<MiniMenuTooltip <MiniMenuTooltip
key={emoji} key={emoji}
@ -319,15 +340,16 @@ function MiniMessageMenu({
children={ children={
usePickerTrigger ? ( usePickerTrigger ? (
<PopoverTrigger <PopoverTrigger
render={ render={({ onClick }) => (
<Button <Button
onClick={onClick}
aria-label="Add reaction" aria-label="Add reaction"
variant="ghost" variant="ghost"
className="h-8 w-8" className="h-8 w-8"
> >
<Laugh /> <Laugh />
</Button> </Button>
} )}
/> />
) : ( ) : (
<Button <Button
@ -397,7 +419,7 @@ function MiniMessageMenu({
/> />
)} )}
</Card> </Card>
</div> </motion.div>
); );
} }
@ -538,16 +560,25 @@ export default function MessageContextMenu({
[], [],
); );
const [mainDrawerOpen, setMainDrawerOpen] = useState(false); const [mainDrawerOpen, setMainDrawerOpen] = useState(false);
const [contextMenuOpen, setContextMenuOpen] = useState(false);
const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false); const [reactionDrawerOpen, setReactionDrawerOpen] = useState(false);
const [pickerOpen, setPickerOpen] = useState(false); const [pickerOpen, setPickerOpen] = useState(false);
const [pickerClosing, setPickerClosing] = useState(false);
const [fadeMiniMenu, setFadeMiniMenu] = useState(false);
const { ranks } = useEmojiRanks(); const { ranks } = useEmojiRanks();
const quickReactions = getRecentEmojis(ranks, 3); const quickReactions = getRecentEmojis(ranks, 3);
const menuReactions = getRecentEmojis(ranks, 5); const menuReactions = getRecentEmojis(ranks, 5);
function selectReaction(emoji: string) { function selectReaction(emoji: string) {
void onReact(emoji); void onReact(emoji);
setActiveMiniMenu(null);
setReactionDrawerOpen(false); setReactionDrawerOpen(false);
setPickerOpen(false); setPickerOpen(false);
setPickerClosing(false);
}
function setPickerVisibility(open: boolean) {
setPickerOpen(open);
setPickerClosing(!isMobile && !open);
} }
const mainDrawerComponents = useMemo( const mainDrawerComponents = useMemo(
() => () =>
@ -578,20 +609,28 @@ export default function MessageContextMenu({
onPointerLeave={scheduleMiniMenuClose} onPointerLeave={scheduleMiniMenuClose}
> >
{children} {children}
{!isMobile && !hideMiniMenu && activeMenuId === messageId && ( <AnimatePresence onExitComplete={() => setFadeMiniMenu(false)}>
<MiniMessageMenu {!isMobile &&
isOwnMessage={isOwnMessage} !hideMiniMenu &&
onDelete={() => deleteMessage(messageId)} (activeMenuId === messageId ||
onEdit={() => onSetEditing(true)} contextMenuOpen ||
onOpenMenu={openMenu} pickerOpen ||
onOpenPicker={() => setPickerOpen(true)} pickerClosing) && (
usePickerTrigger={!isMobile} <MiniMessageMenu
onReact={selectReaction} fadeOut={fadeMiniMenu}
quickReactions={quickReactions} isOwnMessage={isOwnMessage}
onReply={() => setReplyTo(messageId)} onDelete={() => deleteMessage(messageId)}
shiftIsPressed={shiftIsPressed} onEdit={() => onSetEditing(true)}
/> onOpenMenu={openMenu}
)} onOpenPicker={() => setPickerVisibility(true)}
usePickerTrigger={!isMobile}
onReact={selectReaction}
quickReactions={quickReactions}
onReply={() => setReplyTo(messageId)}
shiftIsPressed={shiftIsPressed}
/>
)}
</AnimatePresence>
</div> </div>
); );
} }
@ -636,14 +675,14 @@ export default function MessageContextMenu({
emojis={menuReactions} emojis={menuReactions}
onMore={() => { onMore={() => {
setReactionDrawerOpen(false); setReactionDrawerOpen(false);
setPickerOpen(true); setPickerVisibility(true);
}} }}
onSelect={selectReaction} onSelect={selectReaction}
/> />
</div> </div>
</DrawerContent> </DrawerContent>
</Drawer> </Drawer>
<Drawer open={pickerOpen} onOpenChange={setPickerOpen}> <Drawer open={pickerOpen} onOpenChange={setPickerVisibility}>
<DrawerContent> <DrawerContent>
<DrawerTitle className="sr-only">Choose an emoji</DrawerTitle> <DrawerTitle className="sr-only">Choose an emoji</DrawerTitle>
<DrawerDescription className="sr-only"> <DrawerDescription className="sr-only">
@ -672,8 +711,17 @@ export default function MessageContextMenu({
}); });
return ( return (
<Popover open={pickerOpen} onOpenChange={setPickerOpen}> <Popover
<ContextMenu> open={pickerOpen}
onOpenChange={(open, eventDetails) => {
setPickerVisibility(open);
setFadeMiniMenu(!open && eventDetails.reason === "outside-press");
}}
onOpenChangeComplete={(open) => {
if (!open) setPickerClosing(false);
}}
>
<ContextMenu onOpenChange={setContextMenuOpen}>
<ContextMenuTrigger render={child} /> <ContextMenuTrigger render={child} />
<MessageMenuContent <MessageMenuContent
components={desktopMenuComponents} components={desktopMenuComponents}
@ -681,7 +729,7 @@ export default function MessageContextMenu({
devEnabled={devEnabled} devEnabled={devEnabled}
isOwnMessage={isOwnMessage} isOwnMessage={isOwnMessage}
messageId={messageId} messageId={messageId}
onAddReaction={() => setPickerOpen(true)} onAddReaction={() => setPickerVisibility(true)}
onReact={selectReaction} onReact={selectReaction}
reactionEmojis={menuReactions} reactionEmojis={menuReactions}
onSetEditing={onSetEditing} onSetEditing={onSetEditing}

Some files were not shown because too many files have changed in this diff Show more