Compare commits

...

9 Commits

Author SHA1 Message Date
lebaudantoine 14885e0ffa 🔖(minor) bump release to 0.1.14
Release emoji reactions.
2025-02-24 12:33:52 +01:00
lebaudantoine 054e7ba945 ️(tilt) clean automatically old images
Tilt live updates generate a new image for each change, ending up storing
a lot of images when you are really developing with Tilt.

I have not found a built-in way of cleaning old images from Tilt documentation,
I create a utility doing the dirty work.
2025-02-24 11:05:52 +01:00
lebaudantoine 25506b6a2a 🩹(frontend) fix missing key in reactions toolbar
Emojis are a constant, so the reaction index remains stable,
but using the index as a key isn't ideal.
2025-02-22 17:43:25 +01:00
lebaudantoine 0498a9e7f9 🩹(frontend) preload notifications translations to prevent flickering
The notifications namespace was being lazy-loaded when the first notification
appeared, causing a screen flicker during translation loading. Now preloading
the namespace during i18n initialization to ensure smooth rendering.
2025-02-21 19:19:13 +01:00
lebaudantoine 0a63270154 🚸(frontend) offer a "clear effect" option
Based on user feedbacks, allow them stopping an effect
explicitly by pressing a "no effect" option.
2025-02-21 18:14:17 +01:00
Arnaud Robin b962dddbf2 (frontend) add emoji reactions feature
Implement a new reactions system allowing users
to send quick emoji responses during video calls.
This feature is present in most visio softwares.

Particulary this commit:
- Add ReactionsButton to control bar with emoji selection
- Support floating emoji animations
2025-02-21 13:49:38 +01:00
lebaudantoine 16929bcc83 ♻️(frontend) allow passing placement to a menu
Props supported by react aria. Introduce it.
2025-02-21 13:49:38 +01:00
lebaudantoine 354bdd8bfe 🩹(frontend) fix screen sharing warnings browser compatibility
This feature is far from perfect. Still not working on Safari.
Also, we should display this warning when user share the current
opened window. Minor enhancement, I don't have time currently
to prioritize these enhancements.
2025-02-21 12:14:06 +01:00
Jacques ROUSSEL ccca2b9472 🔧(ci) fix argocd notification
Argocd deployment use numerique-gouv/lasuite-deploiement as source so
the webhook need to tell argocd to refresh apps that use this repos
2025-02-21 11:21:01 +01:00
27 changed files with 394 additions and 32 deletions
+3 -4
View File
@@ -117,8 +117,7 @@ jobs:
- build-and-push-backend
- build-and-push-summary
runs-on: ubuntu-latest
if: |
github.event_name != 'pull_request'
if: github.event_name != 'pull_request'
steps:
-
name: Checkout repository
@@ -126,6 +125,6 @@ jobs:
-
name: Call argocd github webhook
run: |
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/'$GITHUB_REPOSITORY'"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
data='{"ref": "'$GITHUB_REF'","repository": {"html_url":"'$GITHUB_SERVER_URL'/numerique-gouv/lasuite-deploiement"}}'
sig=$(echo -n ${data} | openssl dgst -sha1 -hmac "${{ secrets.ARGOCD_PREPROD_WEBHOOK_SECRET }}" | awk '{print "X-Hub-Signature: sha1="$2}')
curl -X POST -H 'X-GitHub-Event:push' -H "Content-Type: application/json" -H "${sig}" --data "${data}" ${{ vars.ARGOCD_PREPROD_WEBHOOK_URL }}
+7
View File
@@ -1,6 +1,10 @@
load('ext://uibutton', 'cmd_button', 'bool_input', 'location')
load('ext://namespace', 'namespace_create', 'namespace_inject')
namespace_create('meet')
def clean_old_images(image_name):
local('docker images -q %s | tail -n +2 | xargs -r docker rmi' % image_name)
docker_build(
'localhost:5001/meet-backend:latest',
context='..',
@@ -15,6 +19,7 @@ docker_build(
)
]
)
clean_old_images('localhost:5001/meet-backend')
docker_build(
'localhost:5001/meet-frontend:latest',
@@ -26,6 +31,7 @@ docker_build(
sync('../src/frontend', '/home/frontend'),
]
)
clean_old_images('localhost:5001/meet-frontend')
docker_build(
'localhost:5001/meet-summary:latest',
@@ -37,6 +43,7 @@ docker_build(
sync('../src/summary', '/home/summary'),
]
)
clean_old_images('localhost:5001/meet-summary')
k8s_yaml(local('cd ../src/helm && helmfile -n meet -e ${DEV_ENV:-dev} template .'))
+1 -1
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "meet"
version = "0.1.13"
version = "0.1.14"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "meet",
"version": "0.1.13",
"version": "0.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "meet",
"version": "0.1.13",
"version": "0.1.14",
"dependencies": {
"@livekit/components-react": "2.8.1",
"@livekit/components-styles": "1.1.4",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "meet",
"private": true,
"version": "0.1.13",
"version": "0.1.14",
"type": "module",
"scripts": {
"dev": "panda codegen && vite",
@@ -1,4 +1,4 @@
import { useEffect } from 'react'
import { useEffect, useRef, useState } from 'react'
import { useRoomContext } from '@livekit/components-react'
import { Participant, RemoteParticipant, RoomEvent } from 'livekit-client'
import { ToastProvider, toastQueue } from './components/ToastProvider'
@@ -7,11 +7,20 @@ import { NotificationDuration } from './NotificationDuration'
import { Div } from '@/primitives'
import { ChatMessage, isMobileBrowser } from '@livekit/components-core'
import { useNotificationSound } from '@/features/notifications/hooks/useSoundNotification'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { useTranslation } from 'react-i18next'
export const MainNotificationToast = () => {
const room = useRoomContext()
const { triggerNotificationSound } = useNotificationSound()
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
useEffect(() => {
const handleChatMessage = (
chatMessage: ChatMessage,
@@ -34,13 +43,31 @@ export const MainNotificationToast = () => {
}
}, [room, triggerNotificationSound])
const handleEmoji = (emoji: string, participant: Participant) => {
if (!emoji) return
const id = instanceIdRef.current++
setReactions((prev) => [
...prev,
{
id,
emoji,
participant,
},
])
setTimeout(() => {
setReactions((prev) => prev.filter((instance) => instance.id !== id))
}, ANIMATION_DURATION)
}
useEffect(() => {
const handleDataReceived = (
payload: Uint8Array,
participant?: RemoteParticipant
) => {
const decoder = new TextDecoder()
const notificationType = decoder.decode(payload)
const notificationPayload = JSON.parse(decoder.decode(payload))
const notificationType = notificationPayload.type
const data = notificationPayload.data
if (!participant) return
@@ -54,6 +81,9 @@ export const MainNotificationToast = () => {
{ timeout: NotificationDuration.ALERT }
)
break
case NotificationType.ReactionReceived:
handleEmoji(data?.emoji, participant)
break
default:
return
}
@@ -157,9 +187,14 @@ export const MainNotificationToast = () => {
}
}, [room])
// Without this line, when the component first renders,
// the 'notifications' namespace might not be loaded yet
useTranslation(['notifications'])
return (
<Div position="absolute" bottom={0} right={5} zIndex={1000}>
<ToastProvider />
<ReactionPortals reactions={reactions} />
</Div>
)
}
@@ -11,4 +11,5 @@ export const NotificationDuration = {
PARTICIPANT_JOINED: ToastDuration.LONG,
HAND_RAISED: ToastDuration.LONG,
LOWER_HAND: ToastDuration.EXTRA_LONG,
REACTION_RECEIVED: ToastDuration.SHORT,
} as const
@@ -0,0 +1,8 @@
import { NotificationType } from './NotificationType'
export interface NotificationPayload {
type: NotificationType
data?: {
emoji?: string
}
}
@@ -4,4 +4,5 @@ export enum NotificationType {
ParticipantMuted = 'participantMuted',
MessageReceived = 'messageReceived',
LowerHand = 'lowerHand',
ReactionReceived = 'reactionReceived',
}
@@ -5,6 +5,7 @@ import { buildServerApiUrl } from './buildServerApiUrl'
import { useRoomData } from '../hooks/useRoomData'
import { useRoomContext } from '@livekit/components-react'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
export const useMuteParticipant = () => {
const data = useRoomData()
@@ -12,7 +13,10 @@ export const useMuteParticipant = () => {
const notifyParticipant = async (participant: Participant) => {
const encoder = new TextEncoder()
const data = encoder.encode(NotificationType.ParticipantMuted)
const payload: NotificationPayload = {
type: NotificationType.ParticipantMuted,
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, {
reliable: true,
destinationIdentities: [participant.identity],
@@ -20,14 +20,41 @@ export const FullScreenShareWarning = ({
const { localParticipant } = useLocalParticipant()
const screenSharePreferences = useSnapshot(ScreenSharePreferenceStore)
const isFullScreenCapture = useMemo(() => {
const trackLabel =
trackReference.publication?.track?.mediaStreamTrack?.label
return trackLabel?.includes('screen')
const isFullScreenSharing = useMemo(() => {
if (trackReference?.source !== 'screen_share') return false
const mediaStreamTrack = trackReference.publication?.track?.mediaStreamTrack
if (!mediaStreamTrack) return false
// Method 1: Display Surface Check (Chrome & Edge)
const trackSettings = mediaStreamTrack.getSettings()
if (trackSettings?.displaySurface) {
return trackSettings.displaySurface === 'monitor'
}
// Method 2: Track Constraints Check
const constraints = mediaStreamTrack.getConstraints()
if (constraints?.displaySurface) {
return constraints.displaySurface === 'monitor'
}
// Method 3: Label Analysis (Firefox, Safari)
const trackLabel = mediaStreamTrack.label.toLowerCase()
const fullScreenIndicators = [
'monitor',
'screen',
'display',
'entire screen',
'full screen',
]
return fullScreenIndicators.some((indicator) =>
trackLabel.includes(indicator)
)
}, [trackReference])
const shouldShowWarning =
screenSharePreferences.enabled && isFullScreenCapture
screenSharePreferences.enabled && isFullScreenSharing
const handleStopScreenShare = async () => {
if (!localParticipant.isScreenShareEnabled) return
@@ -0,0 +1,143 @@
import { createPortal } from 'react-dom'
import { useState, useEffect, useMemo } from 'react'
import { Text } from '@/primitives'
import { css } from '@/styled-system/css'
import { Participant } from 'livekit-client'
import { useTranslation } from 'react-i18next'
import { Reaction } from '@/features/rooms/livekit/components/controls/ReactionsToggle'
export const ANIMATION_DURATION = 3000
export const ANIMATION_DISTANCE = 300
export const FADE_OUT_THRESHOLD = 0.7
export const REACTION_SPAWN_WIDTH_RATIO = 0.2
export const INITIAL_POSITION = 200
interface FloatingReactionProps {
emoji: string
name?: string
speed?: number
scale?: number
}
export function FloatingReaction({
emoji,
name,
speed = 1,
scale = 1,
}: FloatingReactionProps) {
const [deltaY, setDeltaY] = useState(0)
const [opacity, setOpacity] = useState(1)
const left = useMemo(
() => Math.random() * window.innerWidth * REACTION_SPAWN_WIDTH_RATIO,
[]
)
useEffect(() => {
let start: number | null = null
function animate(timestamp: number) {
if (start === null) start = timestamp
const elapsed = timestamp - start
if (elapsed < 0) {
setOpacity(0)
} else {
const progress = Math.min(elapsed / ANIMATION_DURATION, 1)
const distance = ANIMATION_DISTANCE * speed
const newY = progress * distance
setDeltaY(newY)
if (progress > FADE_OUT_THRESHOLD) {
setOpacity(1 - (progress - FADE_OUT_THRESHOLD) / 0.3)
}
}
if (elapsed < ANIMATION_DURATION) {
requestAnimationFrame(animate)
}
}
const req = requestAnimationFrame(animate)
return () => cancelAnimationFrame(req)
}, [speed])
return (
<div
className={css({
fontSize: '3rem',
position: 'absolute',
display: 'flex',
alignItems: 'center',
flexDirection: 'column',
})}
style={{
left: left,
bottom: INITIAL_POSITION + deltaY,
transform: `scale(${scale})`,
opacity: opacity,
}}
>
<span
className={css({
lineHeight: '45px',
})}
>
{emoji}
</span>
{name && (
<Text
variant="sm"
className={css({
backgroundColor: 'primaryDark.100',
color: 'white',
textAlign: 'center',
borderRadius: '20px',
paddingX: '0.5rem',
paddingY: '0.15rem',
boxShadow: '0 2px 4px rgba(0, 0, 0, 0.1)',
lineHeight: '14px',
})}
>
{name}
</Text>
)}
</div>
)
}
export function ReactionPortal({
emoji,
participant,
}: {
emoji: string
participant: Participant
}) {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const speed = useMemo(() => Math.random() * 1.5 + 0.5, [])
const scale = useMemo(() => Math.max(Math.random() + 0.5, 1), [])
return createPortal(
<div
className={css({
position: 'fixed',
top: 0,
left: 0,
width: '100%',
height: '100%',
pointerEvents: 'none',
})}
>
<FloatingReaction
emoji={emoji}
speed={speed}
scale={scale}
name={participant?.isLocal ? t('you') : participant.name}
/>
</div>,
document.body
)
}
export const ReactionPortals = ({ reactions }: { reactions: Reaction[] }) =>
reactions.map((instance) => (
<ReactionPortal
key={instance.id}
emoji={instance.emoji}
participant={instance.participant}
/>
))
@@ -0,0 +1,94 @@
import { useTranslation } from 'react-i18next'
import { RiEmotionLine } from '@remixicon/react'
import { useState, useRef } from 'react'
import { css } from '@/styled-system/css'
import { useRoomContext } from '@livekit/components-react'
import { Menu, ToggleButton, Button } from '@/primitives'
import { NotificationType } from '@/features/notifications/NotificationType'
import { NotificationPayload } from '@/features/notifications/NotificationPayload'
import {
ANIMATION_DURATION,
ReactionPortals,
} from '@/features/rooms/livekit/components/ReactionPortal'
import { Toolbar as RACToolbar } from 'react-aria-components'
import { Participant } from 'livekit-client'
const EMOJIS = ['👍', '👎', '👏', '❤️', '😂', '😮', '🎉']
export interface Reaction {
id: number
emoji: string
participant: Participant
}
export const ReactionsToggle = () => {
const { t } = useTranslation('rooms', { keyPrefix: 'controls.reactions' })
const [reactions, setReactions] = useState<Reaction[]>([])
const instanceIdRef = useRef(0)
const room = useRoomContext()
const sendReaction = async (emoji: string) => {
const encoder = new TextEncoder()
const payload: NotificationPayload = {
type: NotificationType.ReactionReceived,
data: {
emoji: emoji,
},
}
const data = encoder.encode(JSON.stringify(payload))
await room.localParticipant.publishData(data, { reliable: true })
const newReaction = {
id: instanceIdRef.current++,
emoji,
participant: room.localParticipant,
}
setReactions((prev) => [...prev, newReaction])
// Remove this reaction after animation
setTimeout(() => {
setReactions((prev) =>
prev.filter((instance) => instance.id !== newReaction.id)
)
}, ANIMATION_DURATION)
}
return (
<>
<Menu variant="dark" placement="top">
<ToggleButton
square
variant="primaryDark"
aria-label={t('button')}
tooltip={t('button')}
>
<RiEmotionLine />
</ToggleButton>
<RACToolbar
className={css({
display: 'flex',
})}
>
{EMOJIS.map((emoji, index) => (
<Button
key={index}
onPress={() => sendReaction(emoji)}
aria-label={t('send', { emoji })}
variant="quaternaryText"
size="sm"
>
<span
className={css({
fontSize: '20px',
})}
>
{emoji}
</span>
</Button>
))}
</RACToolbar>
</Menu>
<ReactionPortals reactions={reactions} />
</>
)
}
@@ -15,6 +15,7 @@ import { BlurOnStrong } from '@/components/icons/BlurOnStrong'
import { useTrackToggle } from '@livekit/components-react'
import { Loader } from '@/primitives/Loader'
import { useSyncAfterDelay } from '@/hooks/useSyncAfterDelay'
import { RiProhibited2Line } from '@remixicon/react'
enum BlurRadius {
NONE = 0,
@@ -63,6 +64,11 @@ export const EffectsConfiguration = ({
}
}, [videoTrack, videoTrack?.isMuted])
const clearEffect = async () => {
await videoTrack.stopProcessor()
onSubmit?.(undefined)
}
const toggleEffect = async (
type: ProcessorType,
options: BackgroundOptions
@@ -72,7 +78,7 @@ export const EffectsConfiguration = ({
/**
* Special case: if no video track is available, then we must pass directly the processor into the
* toggle call. Otherwise, the rest of the function below would not have a videoTrack to call
* setProccesor on.
* setProcessor on.
*
* We arrive in this condition when we enter the room with the camera already off.
*/
@@ -95,8 +101,7 @@ export const EffectsConfiguration = ({
try {
if (isSelected(type, options)) {
// Stop processor.
await videoTrack.stopProcessor()
onSubmit?.(undefined)
await clearEffect()
} else if (!processor || processor.serialize().type !== type) {
// Change processor.
const newProcessor = BackgroundProcessorFactory.getProcessor(
@@ -243,6 +248,16 @@ export const EffectsConfiguration = ({
gap: '1.25rem',
})}
>
<ToggleButton
variant="bigSquare"
onPress={async () => {
await clearEffect()
}}
isSelected={!getProcessor()}
isDisabled={processorPendingReveal}
>
<RiProhibited2Line />
</ToggleButton>
<ToggleButton
variant="bigSquare"
aria-label={tooltipLabel(ProcessorType.BLUR, {
@@ -4,6 +4,7 @@ import { css } from '@/styled-system/css'
import { LeaveButton } from '../../components/controls/LeaveButton'
import { SelectToggleDevice } from '../../components/controls/SelectToggleDevice'
import { Track } from 'livekit-client'
import { ReactionsToggle } from '../../components/controls/ReactionsToggle'
import { HandToggle } from '../../components/controls/HandToggle'
import { ScreenShareToggle } from '../../components/controls/ScreenShareToggle'
import { OptionsButton } from '../../components/controls/Options/OptionsButton'
@@ -75,6 +76,7 @@ export function DesktopControlBar({
}
menuVariant="dark"
/>
<ReactionsToggle />
{browserSupportsScreenSharing && (
<ScreenShareToggle
onDeviceError={(error) =>
@@ -12,5 +12,8 @@
"lowerHand": {
"auto": "",
"dismiss": ""
},
"reaction": {
"description": ""
}
}
+6 -1
View File
@@ -86,7 +86,12 @@
"closed": ""
},
"support": "",
"moreOptions": ""
"moreOptions": "",
"reactions": {
"button": "",
"send": "",
"you": ""
}
},
"options": {
"buttonLabel": "",
@@ -12,5 +12,8 @@
"lowerHand": {
"auto": "It seems you have started speaking, so your hand will be lowered.",
"dismiss": "Keep hand raised"
},
"reaction": {
"description": "{{name}} reacted with {{emoji}}"
}
}
+7 -2
View File
@@ -85,7 +85,12 @@
"closed": "Show AI assistant"
},
"support": "Support",
"moreOptions": "More options"
"moreOptions": "More options",
"reactions": {
"button": "Send reaction",
"send": "Send reaction {{emoji}}",
"you": "you"
}
},
"options": {
"buttonLabel": "More Options",
@@ -200,7 +205,7 @@
"fullScreen": "Full screen"
},
"fullScreenWarning": {
"message": "To avoid infinite loop display, do not share your entire screen or browser window. Instead, share a tab or another window.",
"message": "To avoid infinite loop display, do not share your entire screen. Instead, share a tab or another window.",
"stop": "Stop presenting",
"ignore": "Ignore"
}
@@ -12,5 +12,8 @@
"lowerHand": {
"auto": "Il semblerait que vous ayez pris la parole, donc la main va être baissée.",
"dismiss": "Laisser la main levée"
},
"reaction": {
"description": "{{name}} a reagi avec {{emoji}}"
}
}
+7 -2
View File
@@ -85,7 +85,12 @@
"closed": "Afficher l'assistant IA"
},
"support": "Support",
"moreOptions": "Plus d'options"
"moreOptions": "Plus d'options",
"reactions": {
"button": "Envoyer une réaction",
"send": "Envoyer la réaction {{emoji}}",
"you": "vous"
}
},
"options": {
"buttonLabel": "Plus d'options",
@@ -200,7 +205,7 @@
"fullScreen": "Plein écran"
},
"fullScreenWarning": {
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran ni de votre fenêtre de navigateur. Partagez plutôt un onglet ou une autre fenêtre.",
"message": "Pour éviter l'affichage en boucle infinie, ne partagez pas l'intégralité de votre écran. Partagez plutôt un onglet ou une autre fenêtre.",
"stop": "Arrêter la présentation",
"ignore": "Ignorer"
}
+3 -1
View File
@@ -9,15 +9,17 @@ import { Box } from './Box'
export const Menu = ({
children,
variant = 'light',
placement,
}: {
children: [trigger: ReactNode, menu: ReactNode]
variant?: 'dark' | 'light'
placement?: 'bottom' | 'top' | 'left' | 'right'
}) => {
const [trigger, menu] = children
return (
<MenuTrigger>
{trigger}
<StyledPopover>
<StyledPopover placement={placement}>
<Box size="sm" type="popover" variant={variant}>
{menu}
</Box>
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "mail_mjml",
"version": "0.1.13",
"version": "0.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "mail_mjml",
"version": "0.1.13",
"version": "0.1.14",
"license": "MIT",
"dependencies": {
"@html-to/text-cli": "0.5.4",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "mail_mjml",
"version": "0.1.13",
"version": "0.1.14",
"description": "An util to generate html and text django's templates from mjml templates",
"type": "module",
"dependencies": {
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "sdk",
"version": "0.0.0",
"version": "0.1.14",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "sdk",
"version": "0.0.0",
"version": "0.1.14",
"license": "ISC",
"workspaces": [
"./library",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "sdk",
"version": "0.0.0",
"version": "0.1.14",
"author": "",
"license": "ISC",
"description": "",
+1 -1
View File
@@ -1,7 +1,7 @@
[project]
name = "summary"
version = "0.1.0"
version = "0.1.14"
dependencies = [
"fastapi[standard]>=0.105.0",
"uvicorn>=0.24.0",