Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
beeb418496 | ||
|
|
0dc362a5dc | ||
|
|
2981a9ddd8 | ||
|
|
1971c18034 | ||
|
|
eb8f6ef902 | ||
|
|
d2e2d3e768 | ||
|
|
4eadfed9af | ||
|
|
e446039d1f | ||
|
|
f64df3dcf1 | ||
|
|
612449066d | ||
|
|
18bcc9ee37 | ||
|
|
c34fcfedda | ||
|
|
11f8ec03bc | ||
|
|
50718e47ca | ||
|
|
2ffe000bf5 | ||
|
|
cd7ab00d80 | ||
|
|
ddeb36db47 | ||
|
|
4e5a75074a | ||
|
|
391ba5196c | ||
|
|
3e56d0a656 | ||
|
|
afbcea7b66 | ||
|
|
4f582c6ad7 | ||
|
|
8b8d6fd0e0 | ||
|
|
cabad628b4 | ||
|
|
f4f454f58e | ||
|
|
97a6313e03 | ||
|
|
5510719fb2 | ||
|
|
7cae785351 | ||
|
|
69526b67eb | ||
|
|
bde13e0fab | ||
|
|
1207ecc9d7 | ||
|
|
8c21dbaade | ||
|
|
825cb75cb7 | ||
|
|
554da08628 | ||
|
|
f070ab7f67 | ||
|
|
1bfbb80f6d | ||
|
|
515ee72945 | ||
|
|
6a6b62216d | ||
|
|
95b0a6a1ae | ||
|
|
28ffd591b7 | ||
|
|
5c17988e5b | ||
|
|
9696b21b1b | ||
|
|
6deeb76124 | ||
|
|
1da3d5e2c6 | ||
|
|
b71a118db0 | ||
|
|
d0962d77e1 | ||
|
|
170e18af1c | ||
|
|
e4a3dbd7f7 |
88
.github/workflows/netlify-livekit.yaml
vendored
Normal file
88
.github/workflows/netlify-livekit.yaml
vendored
Normal file
@@ -0,0 +1,88 @@
|
||||
name: Netlify LiveKit Experiment
|
||||
on:
|
||||
workflow_run:
|
||||
workflows: ["Build"]
|
||||
types:
|
||||
- completed
|
||||
branches:
|
||||
- "livekit-experiment"
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
deployments: write
|
||||
# Important: the 'branches' filter above will match the 'livekit-experiment' branch on forks,
|
||||
# so we need to check the head repo too in order to not run on PRs from forks
|
||||
# We check the branch name again too just for completeness
|
||||
# (Is there a nicer way to see if a PR is from a fork?)
|
||||
if: github.event.workflow_run.conclusion == 'success' && github.event.workflow_run.head_repository.full_name == 'vector-im/element-call' && github.event.workflow_run.head_branch == 'livekit-experiment'
|
||||
steps:
|
||||
- name: Create Deployment
|
||||
uses: bobheadxi/deployments@v1
|
||||
id: deployment
|
||||
with:
|
||||
step: start
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
env: livekit-experiment-branch-cd
|
||||
ref: ${{ github.event.workflow_run.head_sha }}
|
||||
|
||||
- name: "Download artifact"
|
||||
uses: actions/github-script@v3.1.0
|
||||
with:
|
||||
script: |
|
||||
const artifacts = await github.actions.listWorkflowRunArtifacts({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
run_id: ${{ github.event.workflow_run.id }},
|
||||
});
|
||||
const matchArtifact = artifacts.data.artifacts.filter((artifact) => {
|
||||
return artifact.name == "build"
|
||||
})[0];
|
||||
const download = await github.actions.downloadArtifact({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
artifact_id: matchArtifact.id,
|
||||
archive_format: 'zip',
|
||||
});
|
||||
const fs = require('fs');
|
||||
fs.writeFileSync('${{github.workspace}}/build.zip', Buffer.from(download.data));
|
||||
|
||||
- name: Extract Artifacts
|
||||
run: unzip -d dist build.zip && rm build.zip
|
||||
|
||||
- name: Add redirects file
|
||||
# We fetch from github directly as we don't bother checking out the repo
|
||||
run: curl -s https://raw.githubusercontent.com/vector-im/element-call/livekit-experiment/config/netlify_redirects > dist/_redirects
|
||||
|
||||
- name: Add config file
|
||||
run: curl -s https://raw.githubusercontent.com/vector-im/element-call/livekit-experiment/config/element_io_preview.json > dist/config.json
|
||||
|
||||
- name: Deploy to Netlify
|
||||
id: netlify
|
||||
uses: nwtgck/actions-netlify@v1.2.3
|
||||
with:
|
||||
publish-dir: dist
|
||||
deploy-message: "Deploy from GitHub Actions"
|
||||
production-branch: livekit-experiment
|
||||
production-deploy: true
|
||||
# These don't work because we're in workflow_run
|
||||
enable-pull-request-comment: false
|
||||
enable-commit-comment: false
|
||||
github-deployment-environment: livekit-experiment
|
||||
env:
|
||||
NETLIFY_AUTH_TOKEN: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
NETLIFY_SITE_ID: e3b9fa82-c040-4db6-b4bf-42b524d57423
|
||||
timeout-minutes: 1
|
||||
|
||||
- name: Update deployment status
|
||||
uses: bobheadxi/deployments@v1
|
||||
if: always()
|
||||
with:
|
||||
step: finish
|
||||
override: false
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
status: ${{ job.status }}
|
||||
env: ${{ steps.deployment.outputs.env }}
|
||||
deployment_id: ${{ steps.deployment.outputs.deployment_id }}
|
||||
env_url: ${{ steps.netlify.outputs.deploy-url }}
|
||||
6
.github/workflows/test.yaml
vendored
6
.github/workflows/test.yaml
vendored
@@ -1,6 +1,8 @@
|
||||
name: Run jest tests
|
||||
on:
|
||||
pull_request: {}
|
||||
push:
|
||||
branches: [livekit]
|
||||
jobs:
|
||||
jest:
|
||||
name: Run jest tests
|
||||
@@ -16,3 +18,7 @@ jobs:
|
||||
run: "yarn install"
|
||||
- name: Jest
|
||||
run: "yarn run test"
|
||||
- name: Upload to codecov
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
flags: unittests
|
||||
|
||||
@@ -53,7 +53,7 @@
|
||||
"i18next-browser-languagedetector": "^6.1.8",
|
||||
"i18next-http-backend": "^1.4.4",
|
||||
"lodash": "^4.17.21",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#3cfad3cdeb7b19b8e0e7015784efd803cb9542f1",
|
||||
"matrix-js-sdk": "github:matrix-org/matrix-js-sdk#f884c78579c336a03bc20ff8f4e92c46582822b6",
|
||||
"matrix-widget-api": "^1.3.1",
|
||||
"mermaid": "^8.13.8",
|
||||
"normalize.css": "^8.0.1",
|
||||
@@ -118,6 +118,11 @@
|
||||
"\\.(css|less|svg)+$": "identity-obj-proxy",
|
||||
"^\\./IndexedDBWorker\\?worker$": "<rootDir>/test/mocks/workerMock.ts",
|
||||
"^\\./olm$": "<rootDir>/test/mocks/olmMock.ts"
|
||||
}
|
||||
},
|
||||
"collectCoverage": true,
|
||||
"coverageReporters": [
|
||||
"text",
|
||||
"cobertura"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
"Your recent calls": "Скорошните ви разговори",
|
||||
"{{count}} people connected|one": "{{count}} човек се свърза",
|
||||
"{{count}} people connected|other": "{{count}} човека се звързаха",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, разговорът ви приключи",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{name}} is presenting": "{{name}} презентира",
|
||||
"{{name}} is talking…": "{{name}} говори…",
|
||||
|
||||
@@ -62,7 +62,7 @@
|
||||
"Microphone permissions needed to join the call.": "Přístup k mikrofonu je nutný pro připojení se k hovoru.",
|
||||
"Microphone {{n}}": "Mikrofon {{n}}",
|
||||
"Microphone": "Mikrofon",
|
||||
"Login to your account": "Přihlásit se ke svůmu účtu",
|
||||
"Login to your account": "Přihlásit se ke svému účtu",
|
||||
"Login": "Přihlášení",
|
||||
"Logging in…": "Přihlašování se…",
|
||||
"Local volume": "Lokální hlasitost",
|
||||
@@ -78,7 +78,6 @@
|
||||
"{{name}} is talking…": "{{name}} mluví…",
|
||||
"{{name}} is presenting": "{{name}} prezentuje",
|
||||
"{{name}} (Connecting...)": "{{name}} (Připojení...)",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, váš hovor je nyní ukončen",
|
||||
"{{count}} people connected|other": "{{count}} lidí připojeno",
|
||||
"{{count}} people connected|one": "{{count}} lidí připojeno",
|
||||
"This will make a speaker's audio seem as if it is coming from where their tile is positioned on screen. (Experimental feature: this may impact the stability of audio.)": "Tato možnost způsobí, že zvuk účastníků hovoru se bude tvářit jako by přicházel z místa, kde jsou umístěni na obrazovce.(Experimentální možnost: může způsobit nestabilitu audia.)",
|
||||
|
||||
@@ -111,7 +111,6 @@
|
||||
"Your recent calls": "Deine letzten Anrufe",
|
||||
"{{count}} people connected|one": "{{count}} Person verbunden",
|
||||
"{{count}} people connected|other": "{{count}} Personen verbunden",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, dein Anruf wurde beendet",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{name}} is presenting": "{{name}} präsentiert",
|
||||
"{{name}} is talking…": "{{name}} spricht …",
|
||||
@@ -137,5 +136,11 @@
|
||||
"Your feedback": "Deine Rückmeldung",
|
||||
"Thanks, we received your feedback!": "Danke, wir haben deine Rückmeldung erhalten!",
|
||||
"Submitting…": "Sende …",
|
||||
"Submit": "Absenden"
|
||||
"Submit": "Absenden",
|
||||
"{{count}} stars|other": "{{count}} Sterne",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, dein Anruf wurde beendet.",
|
||||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Wir würden uns freuen, deine Rückmeldung zu hören, um deine Erfahrung verbessern zu können.</0>",
|
||||
"How did it go?": "Wie ist es gelaufen?",
|
||||
"{{count}} stars|one": "{{count}} Stern",
|
||||
"<0>Thanks for your feedback!</0>": "<0>Danke für deine Rückmeldung!</0>"
|
||||
}
|
||||
|
||||
@@ -90,7 +90,6 @@
|
||||
"{{name}} is presenting": "{{name}} παρουσιάζει",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Αναμονή για βίντεο...)",
|
||||
"{{name}} (Connecting...)": "{{name}} (Συνδέεται...)",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, η κλήση σας τερματίστηκε",
|
||||
"{{count}} people connected|other": "{{count}} άτομα συνδεδεμένα",
|
||||
"{{count}} people connected|one": "{{count}} άτομο συνδεδεμένο"
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"Close": "Close",
|
||||
"Confirm password": "Confirm password",
|
||||
"Connection lost": "Connection lost",
|
||||
"Connectivity to the server has been lost.": "Connectivity to the server has been lost.",
|
||||
"Copied!": "Copied!",
|
||||
"Copy": "Copy",
|
||||
"Copy and share this call link": "Copy and share this call link",
|
||||
|
||||
@@ -118,7 +118,6 @@
|
||||
"{{name}} is talking…": "{{name}} está hablando…",
|
||||
"{{name}} is presenting": "{{name}} está presentando",
|
||||
"{{name}} (Connecting...)": "{{name}} (Conectando...)",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, tu llamada ha finalizado",
|
||||
"{{count}} people connected|other": "{{count}} personas conectadas",
|
||||
"{{count}} people connected|one": "{{count}} persona conectada",
|
||||
"Element Call Home": "Inicio de Element Call",
|
||||
|
||||
@@ -10,7 +10,6 @@
|
||||
"{{name}} is talking…": "{{nimi}} räägib…",
|
||||
"{{name}} is presenting": "{{nimi}} esitab",
|
||||
"{{name}} (Connecting...)": "{{nimi}} (ühendamisel...)",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, sinu kõne on nüüd lõppenud",
|
||||
"{{count}} people connected|other": "{{count}} osalejat liitunud",
|
||||
"{{count}} people connected|one": "{{count}} osaleja liitunud",
|
||||
"Invite people": "Kutsu inimesi",
|
||||
@@ -137,5 +136,11 @@
|
||||
"Submitting…": "Saadan…",
|
||||
"Submit": "Saada",
|
||||
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Kui selle rakenduse kasutamisel tekib sul probleeme või lihtsalt soovid oma arvamust avaldada, siis palun täida alljärgnev lühike kirjeldus.",
|
||||
"Feedback": "Tagasiside"
|
||||
"Feedback": "Tagasiside",
|
||||
"{{count}} stars|one": "{{count}} tärn",
|
||||
"{{count}} stars|other": "{{count}} tärni",
|
||||
"How did it go?": "Kuidas sujus?",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, sinu kõne on lõppenud.",
|
||||
"<0>Thanks for your feedback!</0>": "<0>Täname Sind tagasiside eest!</0>",
|
||||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Meie rakenduse paremaks muutmiseks me hea meelega ootame Sinu arvamusi.</0>"
|
||||
}
|
||||
|
||||
@@ -62,7 +62,6 @@
|
||||
"{{roomName}} - Walkie-talkie call": "{{roomName}} - تماس واکی-تاکی",
|
||||
"{{name}} is talking…": "{{name}} در حال صحبت است…",
|
||||
"{{name}} is presenting": "{{name}} حاضر است",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}} تماس شما پایان یافت",
|
||||
"{{count}} people connected|other": "{{count}} نفر متصل هستند",
|
||||
"{{count}} people connected|one": "{{count}} فرد متصل هستند",
|
||||
"Local volume": "حجم داخلی",
|
||||
|
||||
@@ -91,7 +91,6 @@
|
||||
"{{roomName}} - Walkie-talkie call": "{{roomName}} — Appel talkie-walkie",
|
||||
"{{name}} is talking…": "{{name}} est en train de parler…",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, votre appel est désormais terminé",
|
||||
"{{count}} people connected|other": "{{count}} personnes connectées",
|
||||
"{{count}} people connected|one": "{{count}} personne connectée",
|
||||
"Your recent calls": "Appels récents",
|
||||
@@ -137,5 +136,11 @@
|
||||
"Submitting…": "Envoi…",
|
||||
"Submit": "Envoyer",
|
||||
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Si vous rencontrez des problèmes, ou vous voulez simplement faire un commentaire, veuillez nous envoyer une courte description ci-dessous.",
|
||||
"Feedback": "Commentaires"
|
||||
"Feedback": "Commentaires",
|
||||
"{{count}} stars|other": "{{count}} favoris",
|
||||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Nous aimerions avoir vos commentaires afin que nous puissions améliorer votre expérience.</0>",
|
||||
"{{count}} stars|one": "{{count}} favori",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, votre appel est terminé.",
|
||||
"<0>Thanks for your feedback!</0>": "<0>Merci pour votre commentaire !</0>",
|
||||
"How did it go?": "Comment cela s’est-il passé ?"
|
||||
}
|
||||
|
||||
@@ -113,7 +113,6 @@
|
||||
"Your recent calls": "Panggilan Anda terkini",
|
||||
"{{count}} people connected|one": "{{count}} orang terhubung",
|
||||
"{{count}} people connected|other": "{{count}} orang terhubung",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, panggilan Anda sekarang telah berakhir",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{name}} is presenting": "{{name}} sedang mempresentasi",
|
||||
"{{name}} is talking…": "{{name}} sedang berbicara…",
|
||||
@@ -137,5 +136,10 @@
|
||||
"Submit": "Kirim",
|
||||
"Submitting…": "Mengirim",
|
||||
"Thanks, we received your feedback!": "Terima kasih, kami telah menerima masukan Anda!",
|
||||
"Your feedback": "Masukan Anda"
|
||||
"Your feedback": "Masukan Anda",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, panggilan Anda telah berakhir.",
|
||||
"<0>Thanks for your feedback!</0>": "<0>Terima kasih atas masukan Anda!</0>",
|
||||
"How did it go?": "Bagaimana rasanya?",
|
||||
"{{count}} stars|one": "{{count}} bintang",
|
||||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Kami ingin mendengar masukan Anda supaya kami bisa meningkatkan pengalaman Anda.</0>"
|
||||
}
|
||||
|
||||
@@ -94,7 +94,6 @@
|
||||
"WebRTC is not supported or is being blocked in this browser.": "お使いのブラウザでWebRTCがサポートされていないか、またはブロックされています。",
|
||||
"Login to your account": "アカウントにログイン",
|
||||
"Freedom": "自由",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}、通話が終了しました",
|
||||
"Talking…": "話しています…",
|
||||
"Remove": "削除",
|
||||
"No": "いいえ",
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
"<0>Create an account</0> Or <2>Access as a guest</2>": "",
|
||||
"{{count}} people connected|one": "{{count}}명 연결됨",
|
||||
"{{count}} people connected|other": "{{count}}명 연결됨",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}님, 전화가 종료되었습니다",
|
||||
"{{names}}, {{name}}": "{{names}}님, {{name}}님",
|
||||
"{{name}} is presenting": "{{name}}님이 발표 중",
|
||||
"{{name}} is talking…": "{{name}}님이 말하는 중…",
|
||||
|
||||
@@ -115,7 +115,6 @@
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{name}} is talking…": "{{name}} mówi…",
|
||||
"{{name}} is presenting": "{{name}} prezentuje",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, twoje połączenie zostało zakończone",
|
||||
"{{count}} people connected|one": "{{count}} osoba połączona",
|
||||
"This feature is only supported on Firefox.": "Ta funkcjonalność jest dostępna tylko w Firefox.",
|
||||
"Copy": "Kopiuj",
|
||||
@@ -131,5 +130,17 @@
|
||||
"Use the upcoming grid system": "Użyj nadchodzącego systemu siatek",
|
||||
"This site is protected by ReCAPTCHA and the Google <2>Privacy Policy</2> and <6>Terms of Service</6> apply.<9></9>By clicking \"Register\", you agree to our <12>Terms and conditions</12>": "Ta strona jest chroniona przez ReCAPTCHA, więc obowiązują na niej <2>Polityka prywatności</2> i <6>Warunki świadczenia usług</6> Google.<9></9>Klikając \"Zarejestruj się\", zgadzasz się na nasze <12>Warunki świadczenia usług</12>",
|
||||
"<0></0><1></1>You may withdraw consent by unchecking this box. If you are currently in a call, this setting will take effect at the end of the call.": "<0></0><1></1>Możesz wycofać swoją zgodę poprzez odznaczenie tego pola. Jeśli już jesteś w trakcie rozmowy, opcja zostanie zastosowana po jej zakończeniu.",
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Uczestnicząc w tej becie, upoważniasz nas do zbierania anonimowych danych, które wykorzystamy do ulepszenia produktu. Dowiedz się więcej na temat danych, które zbieramy w naszej <2>Polityce prywatności</2> i <5>Polityce ciasteczek</5>."
|
||||
"By participating in this beta, you consent to the collection of anonymous data, which we use to improve the product. You can find more information about which data we track in our <2>Privacy Policy</2> and our <5>Cookie Policy</5>.": "Uczestnicząc w tej becie, upoważniasz nas do zbierania anonimowych danych, które wykorzystamy do ulepszenia produktu. Dowiedz się więcej na temat danych, które zbieramy w naszej <2>Polityce prywatności</2> i <5>Polityce ciasteczek</5>.",
|
||||
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Jeśli posiadasz problemy lub chciałbyś zgłosić swoją opinię, wyślij nam krótki opis.",
|
||||
"Thanks, we received your feedback!": "Dziękujemy, otrzymaliśmy Twoją opinię!",
|
||||
"Feedback": "Opinia użytkownika",
|
||||
"Submitting…": "Wysyłanie…",
|
||||
"Submit": "Wyślij",
|
||||
"Your feedback": "Twoje opinie",
|
||||
"{{count}} stars|other": "{{count}} gwiazdki",
|
||||
"{{count}} stars|one": "{{count}} gwiazdka",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, Twoje połączenie zostało zakończone.",
|
||||
"<0>Thanks for your feedback!</0>": "<0>Dziękujemy za Twoją opinię!</0>",
|
||||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Z przyjemnością wysłuchamy Twojej opinii, aby poprawić Twoje doświadczenia.</0>",
|
||||
"How did it go?": "Jak poszło?"
|
||||
}
|
||||
|
||||
@@ -116,7 +116,6 @@
|
||||
"Accept camera/microphone permissions to join the call.": "Для присоединения к звонку разрешите доступ к камере/микрофону.",
|
||||
"{{name}} is talking…": "{{name}} говорит…",
|
||||
"{{name}} is presenting": "{{name}} показывает",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, ваш звонок завершён",
|
||||
"{{count}} people connected|other": "{{count}} подключилось",
|
||||
"{{count}} people connected|one": "{{count}} подключился",
|
||||
"Element Call Home": "Главная Element Call",
|
||||
|
||||
@@ -121,7 +121,6 @@
|
||||
"{{name}} is presenting": "{{name}} prezentuje",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (Čaká sa na video...)",
|
||||
"{{name}} (Connecting...)": "{{name}} (Pripájanie...)",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, váš hovor je teraz ukončený",
|
||||
"{{count}} people connected|other": "{{count}} osôb pripojených",
|
||||
"{{count}} people connected|one": "{{count}} osoba pripojená",
|
||||
"This feature is only supported on Firefox.": "Táto funkcia je podporovaná len v prehliadači Firefox.",
|
||||
@@ -137,5 +136,11 @@
|
||||
"Submitting…": "Odosielanie…",
|
||||
"Submit": "Odoslať",
|
||||
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Ak máte problémy alebo jednoducho chcete poskytnúť spätnú väzbu, pošlite nám krátky popis nižšie.",
|
||||
"Feedback": "Spätná väzba"
|
||||
"Feedback": "Spätná väzba",
|
||||
"{{count}} stars|one": "{{count}} hviezdička",
|
||||
"How did it go?": "Ako to išlo?",
|
||||
"{{count}} stars|other": "{{count}} hviezdičiek",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, váš hovor skončil.",
|
||||
"<0>Thanks for your feedback!</0>": "<0> Ďakujeme za vašu spätnú väzbu!</0>",
|
||||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0> Radi si vypočujeme vašu spätnú väzbu, aby sme mohli zlepšiť vaše skúsenosti.</0>"
|
||||
}
|
||||
|
||||
@@ -84,7 +84,6 @@
|
||||
"This call already exists, would you like to join?": "Bu arama zaten var, katılmak ister misiniz?",
|
||||
"{{count}} people connected|one": "{{count}} kişi bağlı",
|
||||
"{{count}} people connected|other": "{{count}} kişi bağlı",
|
||||
"{{displayName}}, your call is now ended": "Aramanız bitti, {{displayName]}!",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{name}} is presenting": "{{name}} sunuyor",
|
||||
"{{name}} is talking…": "{{name}} konuşuyor…",
|
||||
|
||||
@@ -116,7 +116,6 @@
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{name}} is talking…": "{{name}} балакає…",
|
||||
"{{name}} is presenting": "{{name}} показує",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, ваш виклик завершено",
|
||||
"{{count}} people connected|other": "{{count}} під'єдналися",
|
||||
"{{count}} people connected|one": "{{count}} під'єднується",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Приєднатися до виклику зараз</0><1>Or</1><2>Скопіювати посилання на виклик і приєднатися пізніше</2>",
|
||||
@@ -137,5 +136,11 @@
|
||||
"Submitting…": "Надсилання…",
|
||||
"Submit": "Надіслати",
|
||||
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "Якщо у вас виникли проблеми або ви просто хочете залишити відгук, надішліть нам короткий опис нижче.",
|
||||
"Feedback": "Відгук"
|
||||
"Feedback": "Відгук",
|
||||
"<0>Thanks for your feedback!</0>": "<0>Дякуємо за ваш відгук!</0>",
|
||||
"{{count}} stars|one": "{{count}} зірка",
|
||||
"{{count}} stars|other": "{{count}} зірок",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}}, ваш виклик завершено.",
|
||||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>Ми будемо раді почути ваші відгуки, щоб поліпшити роботу застосунку.</0>",
|
||||
"How did it go?": "Вам усе сподобалось?"
|
||||
}
|
||||
|
||||
@@ -16,7 +16,6 @@
|
||||
"Yes, join call": "Vâng, tham gia cuộc gọi",
|
||||
"Your feedback": "Phản hồi của bạn",
|
||||
"{{count}} people connected|one": "{{count}} người đã kết nối",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}}, cuộc gọi của bạn đã kết thúc",
|
||||
"{{name}} (Connecting...)": "{{name}} (Đang kết nối...)",
|
||||
"Your recent calls": "Cuộc gọi gần đây",
|
||||
"You can't talk at the same time": "Bạn không thể nói cùng thời điểm",
|
||||
@@ -34,5 +33,73 @@
|
||||
"No": "Không",
|
||||
"Invite people": "Mời mọi người",
|
||||
"Join call now": "Tham gia cuộc gọi",
|
||||
"Create account": "Tạo tài khoản"
|
||||
"Create account": "Tạo tài khoản",
|
||||
"{{name}} is presenting": "{{name}} đang thuyết trình",
|
||||
"{{name}} is talking…": "{{name}} đang nói…",
|
||||
"{{names}}, {{name}}": "{{names}}, {{name}}",
|
||||
"{{roomName}} - Walkie-talkie call": "{{roomName}} - Cuộc gọi thoại",
|
||||
"<0>Create an account</0> Or <2>Access as a guest</2>": "<0>Tạo tài khoản</0> Hay <2>Tham gia dưới tên khác</2>",
|
||||
"<0>Join call now</0><1>Or</1><2>Copy call link and join later</2>": "<0>Tham gia cuộc gọi</0><1>hay</1><2>Sao chép liên kết cuộc gọi và tham gia sau</2>",
|
||||
"Accept camera/microphone permissions to join the call.": "Chấp nhận quyền sử dụng máy quay/micrô để tham gia cuộc gọi.",
|
||||
"<0>Submitting debug logs will help us track down the problem.</0>": "<0>Gửi nhật ký gỡ lỗi sẽ giúp chúng tôi theo dõi vấn đề.</0>",
|
||||
"Avatar": "Ảnh đại diện",
|
||||
"Audio": "Âm thanh",
|
||||
"Camera/microphone permissions needed to join the call.": "Cần quyền máy quay/micrô để tham gia cuộc gọi.",
|
||||
"Camera": "Máy quay",
|
||||
"Camera {{n}}": "Máy quay {{n}}",
|
||||
"Call link copied": "Đã sao chép liên kết cuộc gọi",
|
||||
"Copied!": "Đã sao chép!",
|
||||
"Connection lost": "Mất kết nối",
|
||||
"Confirm password": "Xác nhận mật khẩu",
|
||||
"Close": "Đóng",
|
||||
"Change layout": "Thay đổi bố cục",
|
||||
"Debug log": "Nhật ký gỡ lỗi",
|
||||
"Copy": "Sao chép",
|
||||
"Copy and share this call link": "Sao chép và chia sẻ liên kết cuộc gọi này",
|
||||
"Display name": "Tên hiển thị",
|
||||
"Developer Settings": "Cài đặt phát triển",
|
||||
"Developer": "Nhà phát triển",
|
||||
"Details": "Chi tiết",
|
||||
"Download debug logs": "Tải xuống nhật ký gỡ lỗi",
|
||||
"Feedback": "Phản hồi",
|
||||
"Full screen": "Toàn màn hình",
|
||||
"Incompatible versions!": "Phiên bản không tương thích!",
|
||||
"Incompatible versions": "Phiên bản không tương thích",
|
||||
"Include debug logs": "Kèm theo nhật ký gỡ lỗi",
|
||||
"Invite": "Mời",
|
||||
"Join existing call?": "Tham gia cuộc gọi?",
|
||||
"Leave": "Rời",
|
||||
"Loading…": "Đang tải…",
|
||||
"Logging in…": "Đang đăng nhập…",
|
||||
"Login to your account": "Đăng nhập vào tài khoản của bạn",
|
||||
"Microphone": "Micrô",
|
||||
"Microphone {{n}}": "Micrô {{n}}",
|
||||
"Not registered yet? <2>Create an account</2>": "Chưa đăng ký? <2>Tạo tài khoản</2>",
|
||||
"Passwords must match": "Mật khẩu phải khớp",
|
||||
"Press and hold spacebar to talk": "Nhấn và giữ phím space để nói",
|
||||
"Press and hold to talk": "Nhấn và giữ để nói",
|
||||
"Press and hold to talk over {{name}}": "Nhấn và giữ để nói qua {{name}}",
|
||||
"Register": "Đăng ký",
|
||||
"Release spacebar key to stop": "Nhả phím space để ngừng",
|
||||
"Spotlight": "Tiêu điểm",
|
||||
"Submitting…": "Đang gửi…",
|
||||
"Thanks, we received your feedback!": "Cảm ơn, chúng tôi đã nhận được phản hồi!",
|
||||
"Talking…": "Đang nói…",
|
||||
"Walkie-talkie call": "Cuộc gọi thoại",
|
||||
"Walkie-talkie call name": "Tên cuộc gọi thoại",
|
||||
"<0>Already have an account?</0><1><0>Log in</0> Or <2>Access as a guest</2></1>": "<0>Đã có tài khoản?</0><1><0>Đăng nhập</0> Hay <2>Tham gia dưới tên Khách</2></1>",
|
||||
"Exit full screen": "Rời chế độ toàn màn hình",
|
||||
"Profile": "Hồ sơ",
|
||||
"Registering…": "Đang đăng ký…",
|
||||
"Unmute microphone": "Bật micrô",
|
||||
"This call already exists, would you like to join?": "Cuộc gọi đã tồn tại, bạn có muốn tham gia không?",
|
||||
"This feature is only supported on Firefox.": "Tính năng này chỉ được hỗ trợ trên Firefox.",
|
||||
"Speaker {{n}}": "Loa {{n}}",
|
||||
"Recaptcha not loaded": "Chưa tải được Recaptcha",
|
||||
"Debug log request": "Yêu cầu nhật ký gỡ lỗi",
|
||||
"By clicking \"Join call now\", you agree to our <2>Terms and conditions</2>": "Khi nhấn vào \"Tham gia cuộc gọi\", bạn đồng ý với <2>Điều khoản và điều kiện</2> của chúng tôi",
|
||||
"Another user on this call is having an issue. In order to better diagnose these issues we'd like to collect a debug log.": "Một người dùng khác trong cuộc gọi đang gặp vấn đề. Để có thể chẩn đoán tốt hơn chúng tôi muốn thu thập nhật ký gỡ lỗi.",
|
||||
"Accept microphone permissions to join the call.": "Chấp nhận quyền sử dụng micrô để tham gia cuộc gọi.",
|
||||
"<0>Why not finish by setting up a password to keep your account?</0><1>You'll be able to keep your name and set an avatar for use on future calls</1>": "<0>Tại sao lại không hoàn thiện bằng cách đặt mật khẩu để giữ tài khoản của bạn?</0><1>Bạn sẽ có thể giữ tên và đặt ảnh đại diện cho những cuộc gọi tiếp theo.</1>",
|
||||
"Press and hold spacebar to talk over {{name}}": "Nhấn và giữ phím space để nói trên {{name}}"
|
||||
}
|
||||
|
||||
@@ -45,7 +45,6 @@
|
||||
"{{name}} is presenting": "{{name}}正在展示",
|
||||
"{{name}} (Waiting for video...)": "{{name}}(等待视频……)",
|
||||
"{{name}} (Connecting...)": "{{name}} (正在连接……)",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}},您的通话已结束",
|
||||
"{{count}} people connected|other": "{{count}}人已连接",
|
||||
"{{count}} people connected|one": "{{count}}人已连接",
|
||||
"Inspector": "检查器",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
"{{name}} is presenting": "{{name}} 已上線",
|
||||
"{{name}} (Waiting for video...)": "{{name}} (等候視訊中...)",
|
||||
"{{name}} (Connecting...)": "{{name}} (連結中...)",
|
||||
"{{displayName}}, your call is now ended": "{{displayName}},您的通話已結束",
|
||||
"{{count}} people connected|other": "{{count}} 人已連結",
|
||||
"{{count}} people connected|one": "{{count}} 人已連結",
|
||||
"Use the upcoming grid system": "使用即將推出的網格系統",
|
||||
@@ -137,5 +136,11 @@
|
||||
"Submitting…": "正在遞交……",
|
||||
"Submit": "遞交",
|
||||
"If you are experiencing issues or simply would like to provide some feedback, please send us a short description below.": "若您遇到問題或只是想提供一些回饋,請在下方傳送簡短說明給我們。",
|
||||
"Feedback": "回饋"
|
||||
"Feedback": "回饋",
|
||||
"{{count}} stars|other": "{{count}} 個星星",
|
||||
"<0>Thanks for your feedback!</0>": "<0>感謝您的回饋!</0>",
|
||||
"<0>We'd love to hear your feedback so we can improve your experience.</0>": "<0>我們想要聽到您的回饋,如此我們才能改善您的體驗。</0>",
|
||||
"{{count}} stars|one": "{{count}} 個星星",
|
||||
"{{displayName}}, your call has ended.": "{{displayName}},您的通話已結束。",
|
||||
"How did it go?": "進展如何?"
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ import { usePageFocusStyle } from "./usePageFocusStyle";
|
||||
import { SequenceDiagramViewerPage } from "./SequenceDiagramViewerPage";
|
||||
import { InspectorContextProvider } from "./room/GroupCallInspector";
|
||||
import { CrashView, LoadingView } from "./FullScreenView";
|
||||
import { DisconnectedBanner } from "./DisconnectedBanner";
|
||||
import { Initializer } from "./initializer";
|
||||
import { MediaHandlerProvider } from "./settings/useMediaHandler";
|
||||
|
||||
@@ -60,6 +61,7 @@ export default function App({ history }: AppProps) {
|
||||
<InspectorContextProvider>
|
||||
<Sentry.ErrorBoundary fallback={errorPage}>
|
||||
<OverlayProvider>
|
||||
<DisconnectedBanner />
|
||||
<Switch>
|
||||
<SentryRoute exact path="/">
|
||||
<HomePage />
|
||||
|
||||
@@ -25,9 +25,10 @@ import React, {
|
||||
useRef,
|
||||
} from "react";
|
||||
import { useHistory } from "react-router-dom";
|
||||
import { MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { ClientEvent, MatrixClient } from "matrix-js-sdk/src/client";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { ISyncStateData, SyncState } from "matrix-js-sdk/src/sync";
|
||||
|
||||
import { ErrorView } from "./FullScreenView";
|
||||
import {
|
||||
@@ -70,6 +71,8 @@ const loadSession = (): Session => {
|
||||
const saveSession = (session: Session) =>
|
||||
localStorage.setItem("matrix-auth-store", JSON.stringify(session));
|
||||
const clearSession = () => localStorage.removeItem("matrix-auth-store");
|
||||
const isDisconnected = (syncState, syncData) =>
|
||||
syncState === "ERROR" && syncData?.error?.name === "ConnectionError";
|
||||
|
||||
interface ClientState {
|
||||
loading: boolean;
|
||||
@@ -81,6 +84,7 @@ interface ClientState {
|
||||
logout: () => void;
|
||||
setClient: (client: MatrixClient, session: Session) => void;
|
||||
error?: Error;
|
||||
disconnected: boolean;
|
||||
}
|
||||
|
||||
const ClientContext = createContext<ClientState>(null);
|
||||
@@ -98,7 +102,15 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
const history = useHistory();
|
||||
const initializing = useRef(false);
|
||||
const [
|
||||
{ loading, isAuthenticated, isPasswordlessUser, client, userName, error },
|
||||
{
|
||||
loading,
|
||||
isAuthenticated,
|
||||
isPasswordlessUser,
|
||||
client,
|
||||
userName,
|
||||
error,
|
||||
disconnected,
|
||||
},
|
||||
setState,
|
||||
] = useState<ClientProviderState>({
|
||||
loading: true,
|
||||
@@ -107,8 +119,18 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
client: undefined,
|
||||
userName: null,
|
||||
error: undefined,
|
||||
disconnected: false,
|
||||
});
|
||||
|
||||
const onSync = (state: SyncState, _old: SyncState, data: ISyncStateData) => {
|
||||
setState((currentState) => {
|
||||
const disconnected = isDisconnected(state, data);
|
||||
return disconnected === currentState.disconnected
|
||||
? currentState
|
||||
: { ...currentState, disconnected };
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
// In case the component is mounted, unmounted, and remounted quickly (as
|
||||
// React does in strict mode), we need to make sure not to doubly initialize
|
||||
@@ -183,9 +205,10 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
let clientWithListener: MatrixClient;
|
||||
init()
|
||||
.then(({ client, isPasswordlessUser }) => {
|
||||
clientWithListener = client;
|
||||
setState({
|
||||
client,
|
||||
loading: false,
|
||||
@@ -193,7 +216,12 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
isPasswordlessUser,
|
||||
userName: client?.getUserIdLocalpart(),
|
||||
error: undefined,
|
||||
disconnected: isDisconnected(
|
||||
client?.getSyncState,
|
||||
client?.getSyncStateData
|
||||
),
|
||||
});
|
||||
clientWithListener?.on(ClientEvent.Sync, onSync);
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err);
|
||||
@@ -204,9 +232,13 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
isPasswordlessUser: false,
|
||||
userName: null,
|
||||
error: undefined,
|
||||
disconnected: false,
|
||||
});
|
||||
})
|
||||
.finally(() => (initializing.current = false));
|
||||
return () => {
|
||||
clientWithListener?.removeListener(ClientEvent.Sync, onSync);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const changePassword = useCallback(
|
||||
@@ -235,6 +267,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
isPasswordlessUser: false,
|
||||
userName: client.getUserIdLocalpart(),
|
||||
error: undefined,
|
||||
disconnected: false,
|
||||
});
|
||||
},
|
||||
[client]
|
||||
@@ -256,6 +289,10 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
isPasswordlessUser: session.passwordlessUser,
|
||||
userName: newClient.getUserIdLocalpart(),
|
||||
error: undefined,
|
||||
disconnected: isDisconnected(
|
||||
newClient.getSyncState(),
|
||||
newClient.getSyncStateData()
|
||||
),
|
||||
});
|
||||
} else {
|
||||
clearSession();
|
||||
@@ -267,6 +304,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
isPasswordlessUser: false,
|
||||
userName: null,
|
||||
error: undefined,
|
||||
disconnected: false,
|
||||
});
|
||||
}
|
||||
},
|
||||
@@ -284,6 +322,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
isPasswordlessUser: true,
|
||||
userName: "",
|
||||
error: undefined,
|
||||
disconnected: false,
|
||||
});
|
||||
history.push("/");
|
||||
PosthogAnalytics.instance.setRegistrationType(RegistrationType.Guest);
|
||||
@@ -326,6 +365,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
userName,
|
||||
setClient,
|
||||
error: undefined,
|
||||
disconnected,
|
||||
}),
|
||||
[
|
||||
loading,
|
||||
@@ -336,6 +376,7 @@ export const ClientProvider: FC<Props> = ({ children }) => {
|
||||
logout,
|
||||
userName,
|
||||
setClient,
|
||||
disconnected,
|
||||
]
|
||||
);
|
||||
|
||||
|
||||
27
src/DisconnectedBanner.module.css
Normal file
27
src/DisconnectedBanner.module.css
Normal file
@@ -0,0 +1,27 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
.banner {
|
||||
position: absolute;
|
||||
padding: 29px;
|
||||
background-color: var(--quaternary-content);
|
||||
vertical-align: middle;
|
||||
font-size: var(--font-size-body);
|
||||
text-align: center;
|
||||
z-index: 1;
|
||||
top: 76px;
|
||||
width: calc(100% - 58px);
|
||||
}
|
||||
47
src/DisconnectedBanner.tsx
Normal file
47
src/DisconnectedBanner.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import classNames from "classnames";
|
||||
import React, { HTMLAttributes, ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import styles from "./DisconnectedBanner.module.css";
|
||||
import { useClient } from "./ClientContext";
|
||||
|
||||
interface DisconnectedBannerProps extends HTMLAttributes<HTMLElement> {
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DisconnectedBanner({
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
}: DisconnectedBannerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { disconnected } = useClient();
|
||||
|
||||
return (
|
||||
<>
|
||||
{disconnected && (
|
||||
<div className={classNames(styles.banner, className)} {...rest}>
|
||||
{children}
|
||||
{t("Connectivity to the server has been lost.")}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -70,6 +70,7 @@ export enum RegistrationType {
|
||||
interface PlatformProperties {
|
||||
appVersion: string;
|
||||
matrixBackend: "embedded" | "jssdk";
|
||||
callBackend: "livekit" | "full-mesh";
|
||||
}
|
||||
|
||||
interface PosthogSettings {
|
||||
@@ -191,6 +192,7 @@ export class PosthogAnalytics {
|
||||
return {
|
||||
appVersion,
|
||||
matrixBackend: widget ? "embedded" : "jssdk",
|
||||
callBackend: "full-mesh",
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
38
src/array-utils.ts
Normal file
38
src/array-utils.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Gets the index of the last element in the array to satsify the given
|
||||
* predicate.
|
||||
*/
|
||||
// TODO: remove this once TypeScript recognizes the existence of
|
||||
// Array.prototype.findLastIndex
|
||||
export function findLastIndex<T>(
|
||||
array: T[],
|
||||
predicate: (item: T) => boolean
|
||||
): number | null {
|
||||
for (let i = array.length - 1; i >= 0; i--) {
|
||||
if (predicate(array[i])) return i;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Counts the number of elements in an array that satsify the given predicate.
|
||||
*/
|
||||
export const count = <T>(array: T[], predicate: (item: T) => boolean): number =>
|
||||
array.reduce((acc, item) => (predicate(item) ? acc + 1 : acc), 0);
|
||||
@@ -45,6 +45,12 @@ class DependencyLoadStates {
|
||||
|
||||
export class Initializer {
|
||||
private static internalInstance: Initializer;
|
||||
private isInitialized = false;
|
||||
|
||||
public static isInitialized(): boolean {
|
||||
return Initializer.internalInstance?.isInitialized;
|
||||
}
|
||||
|
||||
public static initBeforeReact() {
|
||||
// this maybe also needs to return a promise in the future,
|
||||
// if we have to do async inits before showing the loading screen
|
||||
@@ -223,6 +229,7 @@ export class Initializer {
|
||||
if (this.loadStates.allDepsAreLoaded()) {
|
||||
// resolve if there is no dependency that is not loaded
|
||||
resolve();
|
||||
this.isInitialized = true;
|
||||
}
|
||||
}
|
||||
private initPromise: Promise<void> | null;
|
||||
|
||||
@@ -61,11 +61,11 @@ function waitForSync(client: MatrixClient) {
|
||||
data: ISyncStateData
|
||||
) => {
|
||||
if (state === "PREPARED") {
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
resolve();
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
} else if (state === "ERROR") {
|
||||
reject(data?.error);
|
||||
client.removeListener(ClientEvent.Sync, onSync);
|
||||
reject(data?.error);
|
||||
}
|
||||
};
|
||||
client.on(ClientEvent.Sync, onSync);
|
||||
|
||||
@@ -115,7 +115,6 @@ export function CallEndedView({
|
||||
label={t("Your feedback")}
|
||||
placeholder={t("Your feedback")}
|
||||
type="textarea"
|
||||
required
|
||||
/>
|
||||
</FieldRow>{" "}
|
||||
<FieldRow>
|
||||
|
||||
@@ -82,17 +82,21 @@ limitations under the License.
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
/* To make avatars scale smoothly with their tiles during animations, we
|
||||
override the styles set on the element */
|
||||
--avatarSize: calc(min(var(--tileWidth), var(--tileHeight)) / 2);
|
||||
width: var(--avatarSize) !important;
|
||||
height: var(--avatarSize) !important;
|
||||
border-radius: 10000px !important;
|
||||
/* CSS makes us put a condition here, even though all we want to do is
|
||||
unconditionally select the container so we can use cqmin units */
|
||||
@container videoTile (width > 0) {
|
||||
.avatar {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
/* To make avatars scale smoothly with their tiles during animations, we
|
||||
override the styles set on the element */
|
||||
--avatarSize: 50cqmin; /* Half of the smallest dimension of the tile */
|
||||
width: var(--avatarSize) !important;
|
||||
height: var(--avatarSize) !important;
|
||||
border-radius: 10000px !important;
|
||||
}
|
||||
}
|
||||
|
||||
@media (min-height: 300px) {
|
||||
|
||||
@@ -44,12 +44,7 @@ import {
|
||||
RoomHeaderInfo,
|
||||
VersionMismatchWarning,
|
||||
} from "../Header";
|
||||
import {
|
||||
VideoGrid,
|
||||
useVideoGridLayout,
|
||||
ChildrenProperties,
|
||||
} from "../video-grid/VideoGrid";
|
||||
import { VideoTileContainer } from "../video-grid/VideoTileContainer";
|
||||
import { VideoGrid, useVideoGridLayout } from "../video-grid/VideoGrid";
|
||||
import { GroupCallInspector } from "./GroupCallInspector";
|
||||
import { GridLayoutMenu } from "./GridLayoutMenu";
|
||||
import { Avatar } from "../Avatar";
|
||||
@@ -77,6 +72,7 @@ import { SettingsModal } from "../settings/SettingsModal";
|
||||
import { InviteModal } from "./InviteModal";
|
||||
import { useRageshakeRequestModal } from "../settings/submit-rageshake";
|
||||
import { RageshakeRequestModal } from "./RageshakeRequestModal";
|
||||
import { VideoTile } from "../video-grid/VideoTile";
|
||||
|
||||
const canScreenshare = "getDisplayMedia" in (navigator.mediaDevices ?? {});
|
||||
// There is currently a bug in Safari our our code with cloning and sending MediaStreams
|
||||
@@ -220,6 +216,8 @@ export function InCallView({
|
||||
focused: screenshareFeeds.length === 0 && callFeed === activeSpeaker,
|
||||
isLocal: member.userId === localUserId && deviceId === localDeviceId,
|
||||
presenter,
|
||||
isSpeaker: callFeed === activeSpeaker,
|
||||
largeBaseSize: false,
|
||||
connectionState,
|
||||
});
|
||||
}
|
||||
@@ -232,6 +230,7 @@ export function InCallView({
|
||||
// Add the screenshares too
|
||||
for (const screenshareFeed of screenshareFeeds) {
|
||||
const member = screenshareFeed.getMember()!;
|
||||
const deviceId = screenshareFeed.deviceId!;
|
||||
const connectionState = participants
|
||||
.get(member)
|
||||
?.get(screenshareFeed.deviceId!)?.connectionState;
|
||||
@@ -246,6 +245,9 @@ export function InCallView({
|
||||
focused: true,
|
||||
isLocal: screenshareFeed.isLocal(),
|
||||
presenter: false,
|
||||
isSpeaker: screenshareFeed === activeSpeaker,
|
||||
largeBaseSize: true,
|
||||
placeNear: `${member.userId} ${deviceId}`,
|
||||
connectionState,
|
||||
});
|
||||
}
|
||||
@@ -303,7 +305,7 @@ export function InCallView({
|
||||
}
|
||||
if (maximisedParticipant) {
|
||||
return (
|
||||
<VideoTileContainer
|
||||
<VideoTile
|
||||
targetHeight={bounds.height}
|
||||
targetWidth={bounds.width}
|
||||
key={maximisedParticipant.id}
|
||||
@@ -311,10 +313,10 @@ export function InCallView({
|
||||
getAvatar={renderAvatar}
|
||||
audioContext={audioContext}
|
||||
audioDestination={audioDestination}
|
||||
disableSpeakingIndicator={true}
|
||||
maximised={Boolean(maximisedParticipant)}
|
||||
fullscreen={maximisedParticipant === fullscreenParticipant}
|
||||
onFullscreen={toggleFullscreen}
|
||||
showSpeakingIndicator={false}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -325,17 +327,16 @@ export function InCallView({
|
||||
layout={layout}
|
||||
disableAnimations={prefersReducedMotion || isSafari}
|
||||
>
|
||||
{({ item, ...rest }: ChildrenProperties) => (
|
||||
<VideoTileContainer
|
||||
item={item}
|
||||
{(props) => (
|
||||
<VideoTile
|
||||
getAvatar={renderAvatar}
|
||||
audioContext={audioContext}
|
||||
audioDestination={audioDestination}
|
||||
disableSpeakingIndicator={items.length < 3}
|
||||
maximised={false}
|
||||
fullscreen={false}
|
||||
onFullscreen={toggleFullscreen}
|
||||
{...rest}
|
||||
showSpeakingIndicator={items.length > 2}
|
||||
{...props}
|
||||
/>
|
||||
)}
|
||||
</Grid>
|
||||
|
||||
@@ -65,7 +65,7 @@ export interface UseGroupCallReturnType {
|
||||
localVideoMuted: boolean;
|
||||
error: TranslatedError | null;
|
||||
initLocalCallFeed: () => void;
|
||||
enter: () => void;
|
||||
enter: () => Promise<void>;
|
||||
leave: () => void;
|
||||
toggleLocalVideoMuted: () => void;
|
||||
toggleMicrophoneMuted: () => void;
|
||||
@@ -483,7 +483,7 @@ export function useGroupCall(
|
||||
[groupCall]
|
||||
);
|
||||
|
||||
const enter = useCallback(() => {
|
||||
const enter = useCallback(async () => {
|
||||
if (
|
||||
groupCall.state !== GroupCallState.LocalCallFeedUninitialized &&
|
||||
groupCall.state !== GroupCallState.LocalCallFeedInitialized
|
||||
@@ -498,7 +498,7 @@ export function useGroupCall(
|
||||
// have started tracking by the time calls start getting created.
|
||||
groupCallOTelMembership?.onJoinCall();
|
||||
|
||||
groupCall.enter().catch((error) => {
|
||||
await groupCall.enter().catch((error) => {
|
||||
console.error(error);
|
||||
updateState({ error });
|
||||
});
|
||||
|
||||
@@ -101,6 +101,7 @@ export function useSubmitRageshake(): {
|
||||
body.append("user_agent", userAgent);
|
||||
body.append("installed_pwa", "false");
|
||||
body.append("touch_input", touchInput);
|
||||
body.append("call_backend", "full-mesh");
|
||||
|
||||
if (client) {
|
||||
const userId = client.getUserId();
|
||||
|
||||
@@ -21,14 +21,14 @@ import { MutableRefObject, RefCallback, useCallback } from "react";
|
||||
* same DOM node.
|
||||
*/
|
||||
export const useMergedRefs = <T>(
|
||||
...refs: (MutableRefObject<T | null> | RefCallback<T | null>)[]
|
||||
...refs: (MutableRefObject<T | null> | RefCallback<T | null> | null)[]
|
||||
): RefCallback<T | null> =>
|
||||
useCallback(
|
||||
(value) =>
|
||||
refs.forEach((ref) => {
|
||||
if (typeof ref === "function") {
|
||||
ref(value);
|
||||
} else {
|
||||
} else if (ref !== null) {
|
||||
ref.current = value;
|
||||
}
|
||||
}),
|
||||
|
||||
@@ -18,7 +18,7 @@ limitations under the License.
|
||||
contain: strict;
|
||||
position: relative;
|
||||
flex-grow: 1;
|
||||
padding: 0 20px;
|
||||
padding: 0 20px var(--footerHeight);
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
}
|
||||
@@ -28,7 +28,6 @@ limitations under the License.
|
||||
display: grid;
|
||||
grid-auto-rows: 163px;
|
||||
gap: 8px;
|
||||
padding-bottom: var(--footerHeight);
|
||||
}
|
||||
|
||||
.slot {
|
||||
@@ -37,7 +36,7 @@ limitations under the License.
|
||||
|
||||
@media (min-width: 800px) {
|
||||
.grid {
|
||||
padding: 0 22px;
|
||||
padding: 0 22px var(--footerHeight);
|
||||
}
|
||||
|
||||
.slotGrid {
|
||||
|
||||
@@ -43,8 +43,12 @@ import {
|
||||
fillGaps,
|
||||
forEachCellInArea,
|
||||
cycleTileSize,
|
||||
appendItems,
|
||||
addItems,
|
||||
tryMoveTile,
|
||||
resize,
|
||||
promoteSpeakers,
|
||||
} from "./model";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
|
||||
interface GridState extends Grid {
|
||||
/**
|
||||
@@ -80,15 +84,21 @@ const useGridState = (
|
||||
}),
|
||||
};
|
||||
|
||||
// Step 2: Backfill gaps left behind by removed tiles
|
||||
const grid2 = fillGaps(grid1);
|
||||
// Step 2: Resize the grid if necessary and backfill gaps left behind by
|
||||
// removed tiles
|
||||
// Resizing already takes care of backfilling gaps
|
||||
const grid2 =
|
||||
columns !== grid1.columns ? resize(grid1, columns!) : fillGaps(grid1);
|
||||
|
||||
// Step 3: Add new tiles to the end of the grid
|
||||
const existingItemIds = new Set(
|
||||
grid2.cells.filter((c) => c !== undefined).map((c) => c!.item.id)
|
||||
);
|
||||
const newItems = items.filter((i) => !existingItemIds.has(i.id));
|
||||
const grid3 = appendItems(newItems, grid2);
|
||||
const grid3 = addItems(newItems, grid2);
|
||||
|
||||
// Step 4: Promote speakers to the top
|
||||
promoteSpeakers(grid3);
|
||||
|
||||
return { ...grid3, generation: prevGrid.generation + 1 };
|
||||
},
|
||||
@@ -203,14 +213,10 @@ export const NewVideoGrid: FC<Props> = ({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [slotGrid, slotGridGeneration, gridBounds]);
|
||||
|
||||
const [columns] = useReactiveState<number | null>(
|
||||
// Since grid resizing isn't implemented yet, pick a column count on mount
|
||||
// and stick to it
|
||||
(prevColumns) =>
|
||||
prevColumns !== undefined && prevColumns !== null
|
||||
? prevColumns
|
||||
: // The grid bounds might not be known yet
|
||||
gridBounds.width === 0
|
||||
const columns = useMemo(
|
||||
() =>
|
||||
// The grid bounds might not be known yet
|
||||
gridBounds.width === 0
|
||||
? null
|
||||
: Math.max(2, Math.floor(gridBounds.width * 0.0045)),
|
||||
[gridBounds]
|
||||
@@ -256,7 +262,7 @@ export const NewVideoGrid: FC<Props> = ({
|
||||
enter: { opacity: 1, scale: 1, immediate: disableAnimations },
|
||||
update: ({ item, x, y, width, height }: Tile) =>
|
||||
item.id === dragState.current?.tileId
|
||||
? {}
|
||||
? null
|
||||
: {
|
||||
x,
|
||||
y,
|
||||
@@ -266,65 +272,81 @@ export const NewVideoGrid: FC<Props> = ({
|
||||
},
|
||||
leave: { opacity: 0, scale: 0, immediate: disableAnimations },
|
||||
config: { mass: 0.7, tension: 252, friction: 25 },
|
||||
}),
|
||||
[tiles, disableAnimations]
|
||||
})
|
||||
// react-spring's types are bugged and can't infer the spring type
|
||||
) as unknown as [TransitionFn<Tile, TileSpring>, SpringRef<TileSpring>];
|
||||
|
||||
// Because we're using react-spring in imperative mode, we're responsible for
|
||||
// firing animations manually whenever the tiles array updates
|
||||
useEffect(() => {
|
||||
springRef.start();
|
||||
}, [tiles, springRef]);
|
||||
|
||||
const animateDraggedTile = (endOfGesture: boolean) => {
|
||||
const { tileId, tileX, tileY, cursorX, cursorY } = dragState.current!;
|
||||
const tile = tiles.find((t) => t.item.id === tileId)!;
|
||||
const originIndex = grid!.cells.findIndex((c) => c?.item.id === tileId);
|
||||
const originCell = grid!.cells[originIndex]!;
|
||||
|
||||
springRef.start((_i, controller) => {
|
||||
if ((controller.item as Tile).item.id === tileId) {
|
||||
if (endOfGesture) {
|
||||
return {
|
||||
scale: 1,
|
||||
zIndex: 1,
|
||||
shadow: 1,
|
||||
x: tile.x,
|
||||
y: tile.y,
|
||||
width: tile.width,
|
||||
height: tile.height,
|
||||
immediate: disableAnimations || ((key) => key === "zIndex"),
|
||||
// Allow the tile's position to settle before pushing its
|
||||
// z-index back down
|
||||
delay: (key) => (key === "zIndex" ? 500 : 0),
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
scale: 1.1,
|
||||
zIndex: 2,
|
||||
shadow: 15,
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
immediate:
|
||||
disableAnimations ||
|
||||
((key) => key === "zIndex" || key === "x" || key === "y"),
|
||||
};
|
||||
}
|
||||
} else {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
springRef.current
|
||||
.find((c) => (c.item as Tile).item.id === tileId)
|
||||
?.start(
|
||||
endOfGesture
|
||||
? {
|
||||
scale: 1,
|
||||
zIndex: 1,
|
||||
shadow: 1,
|
||||
x: tile.x,
|
||||
y: tile.y,
|
||||
width: tile.width,
|
||||
height: tile.height,
|
||||
immediate: disableAnimations || ((key) => key === "zIndex"),
|
||||
// Allow the tile's position to settle before pushing its
|
||||
// z-index back down
|
||||
delay: (key) => (key === "zIndex" ? 500 : 0),
|
||||
}
|
||||
: {
|
||||
scale: 1.1,
|
||||
zIndex: 2,
|
||||
shadow: 15,
|
||||
x: tileX,
|
||||
y: tileY,
|
||||
immediate:
|
||||
disableAnimations ||
|
||||
((key) => key === "zIndex" || key === "x" || key === "y"),
|
||||
}
|
||||
);
|
||||
|
||||
const overTile = tiles.find(
|
||||
(t) =>
|
||||
cursorX >= t.x &&
|
||||
cursorX < t.x + t.width &&
|
||||
cursorY >= t.y &&
|
||||
cursorY < t.y + t.height
|
||||
const columns = grid!.columns;
|
||||
const rows = row(grid!.cells.length - 1, grid!) + 1;
|
||||
|
||||
const cursorColumn = Math.floor(
|
||||
(cursorX / slotGrid!.clientWidth) * columns
|
||||
);
|
||||
if (overTile !== undefined && overTile.item.id !== tileId) {
|
||||
setGrid((g) => ({
|
||||
...g!,
|
||||
cells: g!.cells.map((c) => {
|
||||
if (c?.item === overTile.item) return { ...c, item: tile.item };
|
||||
if (c?.item === tile.item) return { ...c, item: overTile.item };
|
||||
return c;
|
||||
}),
|
||||
}));
|
||||
}
|
||||
const cursorRow = Math.floor((cursorY / slotGrid!.clientHeight) * rows);
|
||||
|
||||
const cursorColumnOnTile = Math.floor(
|
||||
((cursorX - tileX) / tile.width) * originCell.columns
|
||||
);
|
||||
const cursorRowOnTile = Math.floor(
|
||||
((cursorY - tileY) / tile.height) * originCell.rows
|
||||
);
|
||||
|
||||
const dest =
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(
|
||||
columns - originCell.columns,
|
||||
cursorColumn - cursorColumnOnTile
|
||||
)
|
||||
) +
|
||||
grid!.columns *
|
||||
Math.max(
|
||||
0,
|
||||
Math.min(rows - originCell.rows, cursorRow - cursorRowOnTile)
|
||||
);
|
||||
|
||||
if (dest !== originIndex) setGrid((g) => tryMoveTile(g, originIndex, dest));
|
||||
};
|
||||
|
||||
// Callback for useDrag. We could call useDrag here, but the default
|
||||
@@ -447,16 +469,19 @@ export const NewVideoGrid: FC<Props> = ({
|
||||
>
|
||||
{slots}
|
||||
</div>
|
||||
{tileTransitions((style, tile) =>
|
||||
children({
|
||||
...style,
|
||||
key: tile.item.id,
|
||||
targetWidth: tile.width,
|
||||
targetHeight: tile.height,
|
||||
item: tile.item,
|
||||
onDragRef: onTileDragRef,
|
||||
})
|
||||
)}
|
||||
{tileTransitions((spring, tile) => (
|
||||
<TileWrapper
|
||||
key={tile.item.id}
|
||||
id={tile.item.id}
|
||||
onDragRef={onTileDragRef}
|
||||
targetWidth={tile.width}
|
||||
targetHeight={tile.height}
|
||||
item={tile.item}
|
||||
{...spring}
|
||||
>
|
||||
{children}
|
||||
</TileWrapper>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -26,7 +26,10 @@ export interface TileDescriptor {
|
||||
member: RoomMember;
|
||||
focused: boolean;
|
||||
presenter: boolean;
|
||||
isSpeaker: boolean;
|
||||
callFeed?: CallFeed;
|
||||
isLocal?: boolean;
|
||||
largeBaseSize: boolean;
|
||||
placeNear?: string;
|
||||
connectionState: ConnectionState;
|
||||
}
|
||||
|
||||
101
src/video-grid/TileWrapper.tsx
Normal file
101
src/video-grid/TileWrapper.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
/*
|
||||
Copyright 2023 New Vector Ltd
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, { FC, memo, ReactNode, RefObject, useRef } from "react";
|
||||
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
||||
import { SpringValue, to } from "@react-spring/web";
|
||||
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
import { ChildrenProperties } from "./VideoGrid";
|
||||
|
||||
interface Props {
|
||||
id: string;
|
||||
onDragRef: RefObject<
|
||||
(
|
||||
tileId: string,
|
||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
||||
) => void
|
||||
>;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
item: TileDescriptor;
|
||||
opacity: SpringValue<number>;
|
||||
scale: SpringValue<number>;
|
||||
shadow: SpringValue<number>;
|
||||
shadowSpread: SpringValue<number>;
|
||||
zIndex: SpringValue<number>;
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
width: SpringValue<number>;
|
||||
height: SpringValue<number>;
|
||||
children: (props: ChildrenProperties) => ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* A wrapper around a tile in a video grid. This component exists to decouple
|
||||
* child components from the grid.
|
||||
*/
|
||||
export const TileWrapper: FC<Props> = memo(
|
||||
({
|
||||
id,
|
||||
onDragRef,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
item,
|
||||
opacity,
|
||||
scale,
|
||||
shadow,
|
||||
shadowSpread,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
children,
|
||||
}) => {
|
||||
const ref = useRef<HTMLElement | null>(null);
|
||||
|
||||
useDrag((state) => onDragRef?.current!(id, state), {
|
||||
target: ref,
|
||||
filterTaps: true,
|
||||
preventScroll: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<>
|
||||
{children({
|
||||
ref,
|
||||
style: {
|
||||
opacity,
|
||||
scale,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
boxShadow: to(
|
||||
[shadow, shadowSpread],
|
||||
(s, ss) => `rgba(0, 0, 0, 0.5) 0px ${s}px ${2 * s}px ${ss}px`
|
||||
),
|
||||
},
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
item,
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -1,97 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import React, { useState } from "react";
|
||||
import { useMemo } from "react";
|
||||
import { RoomMember } from "matrix-js-sdk";
|
||||
|
||||
import { VideoGrid, useVideoGridLayout } from "./VideoGrid";
|
||||
import { VideoTile } from "./VideoTile";
|
||||
import { Button } from "../button";
|
||||
import { ConnectionState } from "../room/useGroupCall";
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
|
||||
export default {
|
||||
title: "VideoGrid",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
},
|
||||
};
|
||||
|
||||
export const ParticipantsTest = () => {
|
||||
const { layout, setLayout } = useVideoGridLayout(false);
|
||||
const [participantCount, setParticipantCount] = useState(1);
|
||||
|
||||
const items: TileDescriptor[] = useMemo(
|
||||
() =>
|
||||
new Array(participantCount).fill(undefined).map((_, i) => ({
|
||||
id: (i + 1).toString(),
|
||||
member: new RoomMember("!fake:room.id", `@user${i}:fake.dummy`),
|
||||
focused: false,
|
||||
presenter: false,
|
||||
connectionState: ConnectionState.Connected,
|
||||
})),
|
||||
[participantCount]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div style={{ display: "flex", width: "100vw", height: "32px" }}>
|
||||
<Button
|
||||
onPress={() =>
|
||||
setLayout(layout === "freedom" ? "spotlight" : "freedom")
|
||||
}
|
||||
>
|
||||
Toggle Layout
|
||||
</Button>
|
||||
{participantCount < 12 && (
|
||||
<Button onPress={() => setParticipantCount((count) => count + 1)}>
|
||||
Add Participant
|
||||
</Button>
|
||||
)}
|
||||
{participantCount > 0 && (
|
||||
<Button onPress={() => setParticipantCount((count) => count - 1)}>
|
||||
Remove Participant
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
display: "flex",
|
||||
width: "100vw",
|
||||
height: "calc(100vh - 32px)",
|
||||
}}
|
||||
>
|
||||
<VideoGrid layout={layout} items={items}>
|
||||
{({ item, ...rest }) => (
|
||||
<VideoTile
|
||||
key={item.id}
|
||||
name={`User ${item.id}`}
|
||||
disableSpeakingIndicator={items.length < 3}
|
||||
connectionState={ConnectionState.Connected}
|
||||
{...rest}
|
||||
/>
|
||||
)}
|
||||
</VideoGrid>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
ParticipantsTest.args = {
|
||||
layout: "freedom",
|
||||
participantCount: 1,
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
Copyright 2022-2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,21 +14,34 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { Key, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { FullGestureState, useDrag, useGesture } from "@use-gesture/react";
|
||||
import React, {
|
||||
ComponentProps,
|
||||
Key,
|
||||
Ref,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import {
|
||||
EventTypes,
|
||||
FullGestureState,
|
||||
Handler,
|
||||
useGesture,
|
||||
} from "@use-gesture/react";
|
||||
import {
|
||||
animated,
|
||||
SpringRef,
|
||||
SpringValue,
|
||||
SpringValues,
|
||||
useSprings,
|
||||
} from "@react-spring/web";
|
||||
import useMeasure from "react-use-measure";
|
||||
import { ResizeObserver as JuggleResizeObserver } from "@juggle/resize-observer";
|
||||
import { ReactDOMAttributes } from "@use-gesture/react/dist/declarations/src/types";
|
||||
|
||||
import styles from "./VideoGrid.module.css";
|
||||
import { Layout } from "../room/GridLayoutMenu";
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
import { TileWrapper } from "./TileWrapper";
|
||||
|
||||
interface TilePosition {
|
||||
x: number;
|
||||
@@ -39,7 +52,7 @@ interface TilePosition {
|
||||
}
|
||||
|
||||
interface Tile {
|
||||
key: Key;
|
||||
key: string;
|
||||
order: number;
|
||||
item: TileDescriptor;
|
||||
remove: boolean;
|
||||
@@ -717,20 +730,18 @@ interface DragTileData {
|
||||
y: number;
|
||||
}
|
||||
|
||||
export interface ChildrenProperties extends ReactDOMAttributes {
|
||||
key: Key;
|
||||
export interface ChildrenProperties {
|
||||
ref: Ref<HTMLElement>;
|
||||
style: ComponentProps<typeof animated.div>["style"];
|
||||
/**
|
||||
* The width this tile will have once its animations have settled.
|
||||
*/
|
||||
targetWidth: number;
|
||||
/**
|
||||
* The height this tile will have once its animations have settled.
|
||||
*/
|
||||
targetHeight: number;
|
||||
item: TileDescriptor;
|
||||
opacity: SpringValue<number>;
|
||||
scale: SpringValue<number>;
|
||||
shadow: SpringValue<number>;
|
||||
zIndex: SpringValue<number>;
|
||||
x: SpringValue<number>;
|
||||
y: SpringValue<number>;
|
||||
width: SpringValue<number>;
|
||||
height: SpringValue<number>;
|
||||
[index: string]: unknown;
|
||||
}
|
||||
|
||||
export interface VideoGridProps {
|
||||
@@ -1063,117 +1074,132 @@ export function VideoGrid({
|
||||
[tiles, layout, gridBounds.width, gridBounds.height, pipXRatio, pipYRatio]
|
||||
);
|
||||
|
||||
const bindTile = useDrag(
|
||||
({ args: [key], active, xy, movement, tap, last, event }) => {
|
||||
event.preventDefault();
|
||||
// Callback for useDrag. We could call useDrag here, but the default
|
||||
// pattern of spreading {...bind()} across the children to bind the gesture
|
||||
// ends up breaking memoization and ruining this component's performance.
|
||||
// Instead, we pass this callback to each tile via a ref, to let them bind the
|
||||
// gesture using the much more sensible ref-based method.
|
||||
const onTileDrag = (
|
||||
tileId: string,
|
||||
{
|
||||
active,
|
||||
xy,
|
||||
movement,
|
||||
tap,
|
||||
last,
|
||||
event,
|
||||
}: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
||||
) => {
|
||||
event.preventDefault();
|
||||
|
||||
if (tap) {
|
||||
onTap(key);
|
||||
return;
|
||||
}
|
||||
if (tap) {
|
||||
onTap(tileId);
|
||||
return;
|
||||
}
|
||||
|
||||
if (layout !== "freedom") return;
|
||||
if (layout !== "freedom") return;
|
||||
|
||||
const dragTileIndex = tiles.findIndex((tile) => tile.key === key);
|
||||
const dragTile = tiles[dragTileIndex];
|
||||
const dragTilePosition = tilePositions[dragTile.order];
|
||||
const dragTileIndex = tiles.findIndex((tile) => tile.key === tileId);
|
||||
const dragTile = tiles[dragTileIndex];
|
||||
const dragTilePosition = tilePositions[dragTile.order];
|
||||
|
||||
const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top];
|
||||
const cursorPosition = [xy[0] - gridBounds.left, xy[1] - gridBounds.top];
|
||||
|
||||
let newTiles = tiles;
|
||||
let newTiles = tiles;
|
||||
|
||||
if (tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused)) {
|
||||
// We're in 1:1 mode, so only the local tile should be draggable
|
||||
if (!dragTile.item.isLocal) return;
|
||||
if (tiles.length === 2 && !tiles.some((t) => t.presenter || t.focused)) {
|
||||
// We're in 1:1 mode, so only the local tile should be draggable
|
||||
if (!dragTile.item.isLocal) return;
|
||||
|
||||
// Position should only update on the very last event, to avoid
|
||||
// compounding the offset on every drag event
|
||||
if (last) {
|
||||
const remotePosition = tilePositions[1];
|
||||
// Position should only update on the very last event, to avoid
|
||||
// compounding the offset on every drag event
|
||||
if (last) {
|
||||
const remotePosition = tilePositions[1];
|
||||
|
||||
const pipGap = getPipGap(
|
||||
gridBounds.width / gridBounds.height,
|
||||
gridBounds.width
|
||||
);
|
||||
const pipMinX = remotePosition.x + pipGap;
|
||||
const pipMinY = remotePosition.y + pipGap;
|
||||
const pipMaxX =
|
||||
remotePosition.x +
|
||||
remotePosition.width -
|
||||
dragTilePosition.width -
|
||||
pipGap;
|
||||
const pipMaxY =
|
||||
remotePosition.y +
|
||||
remotePosition.height -
|
||||
dragTilePosition.height -
|
||||
pipGap;
|
||||
|
||||
const newPipXRatio =
|
||||
(dragTilePosition.x + movement[0] - pipMinX) / (pipMaxX - pipMinX);
|
||||
const newPipYRatio =
|
||||
(dragTilePosition.y + movement[1] - pipMinY) / (pipMaxY - pipMinY);
|
||||
|
||||
setPipXRatio(Math.max(0, Math.min(1, newPipXRatio)));
|
||||
setPipYRatio(Math.max(0, Math.min(1, newPipYRatio)));
|
||||
}
|
||||
} else {
|
||||
const hoverTile = tiles.find(
|
||||
(tile) =>
|
||||
tile.key !== key &&
|
||||
isInside(cursorPosition, tilePositions[tile.order])
|
||||
const pipGap = getPipGap(
|
||||
gridBounds.width / gridBounds.height,
|
||||
gridBounds.width
|
||||
);
|
||||
const pipMinX = remotePosition.x + pipGap;
|
||||
const pipMinY = remotePosition.y + pipGap;
|
||||
const pipMaxX =
|
||||
remotePosition.x +
|
||||
remotePosition.width -
|
||||
dragTilePosition.width -
|
||||
pipGap;
|
||||
const pipMaxY =
|
||||
remotePosition.y +
|
||||
remotePosition.height -
|
||||
dragTilePosition.height -
|
||||
pipGap;
|
||||
|
||||
if (hoverTile) {
|
||||
// Shift the tiles into their new order
|
||||
newTiles = newTiles.map((tile) => {
|
||||
let order = tile.order;
|
||||
if (order < dragTile.order) {
|
||||
if (order >= hoverTile.order) order++;
|
||||
} else if (order > dragTile.order) {
|
||||
if (order <= hoverTile.order) order--;
|
||||
} else {
|
||||
order = hoverTile.order;
|
||||
}
|
||||
const newPipXRatio =
|
||||
(dragTilePosition.x + movement[0] - pipMinX) / (pipMaxX - pipMinX);
|
||||
const newPipYRatio =
|
||||
(dragTilePosition.y + movement[1] - pipMinY) / (pipMaxY - pipMinY);
|
||||
|
||||
let focused;
|
||||
if (tile === hoverTile) {
|
||||
focused = dragTile.focused;
|
||||
} else if (tile === dragTile) {
|
||||
focused = hoverTile.focused;
|
||||
} else {
|
||||
focused = tile.focused;
|
||||
}
|
||||
|
||||
return { ...tile, order, focused };
|
||||
});
|
||||
|
||||
reorderTiles(newTiles, layout);
|
||||
|
||||
setTileState((state) => ({ ...state, tiles: newTiles }));
|
||||
}
|
||||
setPipXRatio(Math.max(0, Math.min(1, newPipXRatio)));
|
||||
setPipYRatio(Math.max(0, Math.min(1, newPipYRatio)));
|
||||
}
|
||||
} else {
|
||||
const hoverTile = tiles.find(
|
||||
(tile) =>
|
||||
tile.key !== tileId &&
|
||||
isInside(cursorPosition, tilePositions[tile.order])
|
||||
);
|
||||
|
||||
if (active) {
|
||||
if (!draggingTileRef.current) {
|
||||
draggingTileRef.current = {
|
||||
key: dragTile.key,
|
||||
offsetX: dragTilePosition.x,
|
||||
offsetY: dragTilePosition.y,
|
||||
x: movement[0],
|
||||
y: movement[1],
|
||||
};
|
||||
} else {
|
||||
draggingTileRef.current.x = movement[0];
|
||||
draggingTileRef.current.y = movement[1];
|
||||
}
|
||||
if (hoverTile) {
|
||||
// Shift the tiles into their new order
|
||||
newTiles = newTiles.map((tile) => {
|
||||
let order = tile.order;
|
||||
if (order < dragTile.order) {
|
||||
if (order >= hoverTile.order) order++;
|
||||
} else if (order > dragTile.order) {
|
||||
if (order <= hoverTile.order) order--;
|
||||
} else {
|
||||
order = hoverTile.order;
|
||||
}
|
||||
|
||||
let focused;
|
||||
if (tile === hoverTile) {
|
||||
focused = dragTile.focused;
|
||||
} else if (tile === dragTile) {
|
||||
focused = hoverTile.focused;
|
||||
} else {
|
||||
focused = tile.focused;
|
||||
}
|
||||
|
||||
return { ...tile, order, focused };
|
||||
});
|
||||
|
||||
reorderTiles(newTiles, layout);
|
||||
|
||||
setTileState((state) => ({ ...state, tiles: newTiles }));
|
||||
}
|
||||
}
|
||||
|
||||
if (active) {
|
||||
if (!draggingTileRef.current) {
|
||||
draggingTileRef.current = {
|
||||
key: dragTile.key,
|
||||
offsetX: dragTilePosition.x,
|
||||
offsetY: dragTilePosition.y,
|
||||
x: movement[0],
|
||||
y: movement[1],
|
||||
};
|
||||
} else {
|
||||
draggingTileRef.current = null;
|
||||
draggingTileRef.current.x = movement[0];
|
||||
draggingTileRef.current.y = movement[1];
|
||||
}
|
||||
} else {
|
||||
draggingTileRef.current = null;
|
||||
}
|
||||
|
||||
api.start(animate(newTiles));
|
||||
},
|
||||
{ filterTaps: true, pointer: { buttons: [1] } }
|
||||
);
|
||||
api.start(animate(newTiles));
|
||||
};
|
||||
|
||||
const onTileDragRef = useRef(onTileDrag);
|
||||
onTileDragRef.current = onTileDrag;
|
||||
|
||||
const onGridGesture = useCallback(
|
||||
(
|
||||
@@ -1220,18 +1246,23 @@ export function VideoGrid({
|
||||
|
||||
return (
|
||||
<div className={styles.videoGrid} ref={gridRef} {...bindGrid()}>
|
||||
{springs.map((style, i) => {
|
||||
{springs.map((spring, i) => {
|
||||
const tile = tiles[i];
|
||||
const tilePosition = tilePositions[tile.order];
|
||||
|
||||
return children({
|
||||
...bindTile(tile.key),
|
||||
...style,
|
||||
key: tile.item.id,
|
||||
targetWidth: tilePosition.width,
|
||||
targetHeight: tilePosition.height,
|
||||
item: tile.item,
|
||||
});
|
||||
return (
|
||||
<TileWrapper
|
||||
key={tile.key}
|
||||
id={tile.key}
|
||||
onDragRef={onTileDragRef}
|
||||
targetWidth={tilePosition.width}
|
||||
targetHeight={tilePosition.height}
|
||||
item={tile.item}
|
||||
{...spring}
|
||||
>
|
||||
{children}
|
||||
</TileWrapper>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -18,12 +18,10 @@ limitations under the License.
|
||||
position: absolute;
|
||||
contain: strict;
|
||||
top: 0;
|
||||
width: var(--tileWidth);
|
||||
height: var(--tileHeight);
|
||||
container-name: videoTile;
|
||||
container-type: size;
|
||||
--tileRadius: 8px;
|
||||
border-radius: var(--tileRadius);
|
||||
box-shadow: rgba(0, 0, 0, 0.5) 0px var(--tileShadow)
|
||||
calc(2 * var(--tileShadow)) var(--tileShadowSpread);
|
||||
overflow: hidden;
|
||||
cursor: pointer;
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
Copyright 2022-2023 New Vector Ltd
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
@@ -14,86 +14,105 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import React, { ForwardedRef, forwardRef } from "react";
|
||||
import { animated, SpringValue } from "@react-spring/web";
|
||||
import React, { ComponentProps, forwardRef, useCallback } from "react";
|
||||
import { animated } from "@react-spring/web";
|
||||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
|
||||
|
||||
import styles from "./VideoTile.module.css";
|
||||
import { ReactComponent as MicIcon } from "../icons/Mic.svg";
|
||||
import { ReactComponent as MicMutedIcon } from "../icons/MicMuted.svg";
|
||||
import { AudioButton, FullscreenButton } from "../button/Button";
|
||||
import { ConnectionState } from "../room/useGroupCall";
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
|
||||
import { useCallFeed } from "./useCallFeed";
|
||||
import { useSpatialMediaStream } from "./useMediaStream";
|
||||
import { useRoomMemberName } from "./useRoomMemberName";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { useMergedRefs } from "../useMergedRefs";
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
connectionState: ConnectionState;
|
||||
speaking?: boolean;
|
||||
audioMuted?: boolean;
|
||||
videoMuted?: boolean;
|
||||
screenshare?: boolean;
|
||||
avatar?: JSX.Element;
|
||||
mediaRef?: React.RefObject<MediaElement>;
|
||||
onOptionsPress?: () => void;
|
||||
localVolume?: number;
|
||||
hasAudio?: boolean;
|
||||
maximised?: boolean;
|
||||
fullscreen?: boolean;
|
||||
onFullscreen?: () => void;
|
||||
item: TileDescriptor;
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onFullscreen: (participant: TileDescriptor) => void;
|
||||
className?: string;
|
||||
showOptions?: boolean;
|
||||
isLocal?: boolean;
|
||||
disableSpeakingIndicator?: boolean;
|
||||
opacity?: SpringValue<number>;
|
||||
scale?: SpringValue<number>;
|
||||
shadow?: SpringValue<number>;
|
||||
shadowSpread?: SpringValue<number>;
|
||||
zIndex?: SpringValue<number>;
|
||||
x?: SpringValue<number>;
|
||||
y?: SpringValue<number>;
|
||||
width?: SpringValue<number>;
|
||||
height?: SpringValue<number>;
|
||||
showSpeakingIndicator: boolean;
|
||||
style?: ComponentProps<typeof animated.div>["style"];
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
getAvatar: (
|
||||
roomMember: RoomMember,
|
||||
width: number,
|
||||
height: number
|
||||
) => JSX.Element;
|
||||
audioContext: AudioContext;
|
||||
audioDestination: AudioNode;
|
||||
}
|
||||
|
||||
export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
(
|
||||
{
|
||||
name,
|
||||
connectionState,
|
||||
speaking,
|
||||
audioMuted,
|
||||
videoMuted,
|
||||
screenshare,
|
||||
avatar,
|
||||
mediaRef,
|
||||
onOptionsPress,
|
||||
localVolume,
|
||||
hasAudio,
|
||||
item,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onFullscreen,
|
||||
className,
|
||||
showOptions,
|
||||
isLocal,
|
||||
// TODO: disableSpeakingIndicator is not used atm.
|
||||
disableSpeakingIndicator,
|
||||
opacity,
|
||||
scale,
|
||||
shadow,
|
||||
shadowSpread,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
width,
|
||||
height,
|
||||
...rest
|
||||
showSpeakingIndicator,
|
||||
style,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
getAvatar,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
},
|
||||
ref
|
||||
tileRef1
|
||||
) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const {
|
||||
isLocal,
|
||||
audioMuted,
|
||||
videoMuted,
|
||||
localVolume,
|
||||
hasAudio,
|
||||
speaking,
|
||||
stream,
|
||||
purpose,
|
||||
} = useCallFeed(item.callFeed);
|
||||
const screenshare = purpose === SDPStreamMetadataPurpose.Screenshare;
|
||||
const { rawDisplayName: name } = useRoomMemberName(item.member);
|
||||
|
||||
const [tileRef2, mediaRef] = useSpatialMediaStream(
|
||||
stream ?? null,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
localVolume,
|
||||
// The feed is muted if it's local audio (because we don't want our own audio,
|
||||
// but it's a hook and we can't call it conditionally so we're stuck with it)
|
||||
// or if there's a maximised feed in which case we always render audio via audio
|
||||
// elements because we wire it up at the video tile container level and only one
|
||||
// video tile container is displayed.
|
||||
isLocal || maximised
|
||||
);
|
||||
|
||||
const tileRef = useMergedRefs(tileRef1, tileRef2);
|
||||
|
||||
const {
|
||||
modalState: videoTileSettingsModalState,
|
||||
modalProps: videoTileSettingsModalProps,
|
||||
} = useModalTriggerState();
|
||||
const onOptionsPress = videoTileSettingsModalState.open;
|
||||
|
||||
const onFullscreenCallback = useCallback(() => {
|
||||
onFullscreen(item);
|
||||
}, [onFullscreen, item]);
|
||||
|
||||
const toolbarButtons: JSX.Element[] = [];
|
||||
if (connectionState == ConnectionState.Connected && !isLocal) {
|
||||
if (item.connectionState == ConnectionState.Connected && !isLocal) {
|
||||
if (hasAudio) {
|
||||
toolbarButtons.push(
|
||||
<AudioButton
|
||||
@@ -111,14 +130,14 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
key="fullscreen"
|
||||
className={styles.button}
|
||||
fullscreen={fullscreen}
|
||||
onPress={onFullscreen}
|
||||
onPress={onFullscreenCallback}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let caption: string;
|
||||
switch (connectionState) {
|
||||
switch (item.connectionState) {
|
||||
case ConnectionState.EstablishingCall:
|
||||
caption = t("{{name}} (Connecting...)", { name });
|
||||
break;
|
||||
@@ -131,68 +150,65 @@ export const VideoTile = forwardRef<HTMLElement, Props>(
|
||||
break;
|
||||
}
|
||||
|
||||
// Firefox doesn't respect the disablePictureInPicture attribute
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
|
||||
|
||||
return (
|
||||
<animated.div
|
||||
className={classNames(styles.videoTile, className, {
|
||||
[styles.isLocal]: isLocal,
|
||||
[styles.speaking]: speaking,
|
||||
[styles.muted]: audioMuted,
|
||||
[styles.screenshare]: screenshare,
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
style={{
|
||||
opacity,
|
||||
scale,
|
||||
zIndex,
|
||||
x,
|
||||
y,
|
||||
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
|
||||
// @ts-ignore React does in fact support assigning custom properties,
|
||||
// but React's types say no
|
||||
"--tileWidth": width?.to((w) => `${w}px`),
|
||||
"--tileHeight": height?.to((h) => `${h}px`),
|
||||
"--tileShadow": shadow?.to((s) => `${s}px`),
|
||||
"--tileShadowSpread": shadowSpread?.to((s) => `${s}px`),
|
||||
}}
|
||||
ref={ref as ForwardedRef<HTMLDivElement>}
|
||||
data-testid="videoTile"
|
||||
{...rest}
|
||||
>
|
||||
{toolbarButtons.length > 0 && !maximised && (
|
||||
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
|
||||
)}
|
||||
{videoMuted && (
|
||||
<>
|
||||
<div className={styles.videoMutedOverlay} />
|
||||
{avatar}
|
||||
</>
|
||||
)}
|
||||
{!maximised &&
|
||||
(screenshare ? (
|
||||
<div className={styles.presenterLabel}>
|
||||
<span>{t("{{name}} is presenting", { name })}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames(styles.infoBubble, styles.memberName)}>
|
||||
{
|
||||
/* If the user is speaking, it's safe to say they're unmuted.
|
||||
<>
|
||||
<animated.div
|
||||
className={classNames(styles.videoTile, className, {
|
||||
[styles.isLocal]: isLocal,
|
||||
[styles.speaking]: speaking && showSpeakingIndicator,
|
||||
[styles.muted]: audioMuted,
|
||||
[styles.screenshare]: screenshare,
|
||||
[styles.maximised]: maximised,
|
||||
})}
|
||||
style={style}
|
||||
ref={tileRef}
|
||||
data-testid="videoTile"
|
||||
>
|
||||
{toolbarButtons.length > 0 && !maximised && (
|
||||
<div className={classNames(styles.toolbar)}>{toolbarButtons}</div>
|
||||
)}
|
||||
{videoMuted && (
|
||||
<>
|
||||
<div className={styles.videoMutedOverlay} />
|
||||
{getAvatar(item.member, targetWidth, targetHeight)}
|
||||
</>
|
||||
)}
|
||||
{!maximised &&
|
||||
(screenshare ? (
|
||||
<div className={styles.presenterLabel}>
|
||||
<span>{t("{{name}} is presenting", { name })}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className={classNames(styles.infoBubble, styles.memberName)}>
|
||||
{
|
||||
/* If the user is speaking, it's safe to say they're unmuted.
|
||||
Mute state is currently sent over to-device messages, which
|
||||
aren't quite real-time, so this is an important kludge to make
|
||||
sure no one appears muted when they've clearly begun talking. */
|
||||
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
|
||||
}
|
||||
<span data-testid="videoTile_caption" title={caption}>
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<video
|
||||
data-testid="videoTile_video"
|
||||
ref={mediaRef}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
/>
|
||||
</animated.div>
|
||||
speaking || !audioMuted ? <MicIcon /> : <MicMutedIcon />
|
||||
}
|
||||
<span data-testid="videoTile_caption" title={caption}>
|
||||
{caption}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
<video
|
||||
data-testid="videoTile_video"
|
||||
ref={mediaRef}
|
||||
playsInline
|
||||
disablePictureInPicture
|
||||
/>
|
||||
</animated.div>
|
||||
{videoTileSettingsModalState.isOpen && !maximised && item.callFeed && (
|
||||
<VideoTileSettingsModal
|
||||
{...videoTileSettingsModalProps}
|
||||
feed={item.callFeed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -1,157 +0,0 @@
|
||||
/*
|
||||
Copyright 2022 New Vector Ltd
|
||||
|
||||
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.
|
||||
*/
|
||||
|
||||
import { SDPStreamMetadataPurpose } from "matrix-js-sdk/src/webrtc/callEventTypes";
|
||||
import React, { FC, memo, RefObject } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { RoomMember } from "matrix-js-sdk/src/models/room-member";
|
||||
import { SpringValue } from "@react-spring/web";
|
||||
import { EventTypes, Handler, useDrag } from "@use-gesture/react";
|
||||
|
||||
import { useCallFeed } from "./useCallFeed";
|
||||
import { useSpatialMediaStream } from "./useMediaStream";
|
||||
import { useRoomMemberName } from "./useRoomMemberName";
|
||||
import { VideoTile } from "./VideoTile";
|
||||
import { VideoTileSettingsModal } from "./VideoTileSettingsModal";
|
||||
import { useModalTriggerState } from "../Modal";
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
|
||||
interface Props {
|
||||
item: TileDescriptor;
|
||||
targetWidth: number;
|
||||
targetHeight: number;
|
||||
getAvatar: (
|
||||
roomMember: RoomMember,
|
||||
width: number,
|
||||
height: number
|
||||
) => JSX.Element;
|
||||
audioContext: AudioContext;
|
||||
audioDestination: AudioNode;
|
||||
disableSpeakingIndicator: boolean;
|
||||
maximised: boolean;
|
||||
fullscreen: boolean;
|
||||
onFullscreen: (item: TileDescriptor) => void;
|
||||
opacity?: SpringValue<number>;
|
||||
scale?: SpringValue<number>;
|
||||
shadow?: SpringValue<number>;
|
||||
shadowSpread?: SpringValue<number>;
|
||||
zIndex?: SpringValue<number>;
|
||||
x?: SpringValue<number>;
|
||||
y?: SpringValue<number>;
|
||||
width?: SpringValue<number>;
|
||||
height?: SpringValue<number>;
|
||||
onDragRef?: RefObject<
|
||||
(
|
||||
tileId: string,
|
||||
state: Parameters<Handler<"drag", EventTypes["drag"]>>[0]
|
||||
) => void
|
||||
>;
|
||||
}
|
||||
|
||||
export const VideoTileContainer: FC<Props> = memo(
|
||||
({
|
||||
item,
|
||||
targetWidth,
|
||||
targetHeight,
|
||||
getAvatar,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
disableSpeakingIndicator,
|
||||
maximised,
|
||||
fullscreen,
|
||||
onFullscreen,
|
||||
onDragRef,
|
||||
...rest
|
||||
}) => {
|
||||
const {
|
||||
isLocal,
|
||||
audioMuted,
|
||||
videoMuted,
|
||||
localVolume,
|
||||
hasAudio,
|
||||
speaking,
|
||||
stream,
|
||||
purpose,
|
||||
} = useCallFeed(item.callFeed);
|
||||
const { rawDisplayName } = useRoomMemberName(item.member);
|
||||
|
||||
const [tileRef, mediaRef] = useSpatialMediaStream(
|
||||
stream ?? null,
|
||||
audioContext,
|
||||
audioDestination,
|
||||
localVolume,
|
||||
// The feed is muted if it's local audio (because we don't want our own audio,
|
||||
// but it's a hook and we can't call it conditionally so we're stuck with it)
|
||||
// or if there's a maximised feed in which case we always render audio via audio
|
||||
// elements because we wire it up at the video tile container level and only one
|
||||
// video tile container is displayed.
|
||||
isLocal || maximised
|
||||
);
|
||||
|
||||
useDrag((state) => onDragRef?.current!(item.id, state), {
|
||||
target: tileRef,
|
||||
filterTaps: true,
|
||||
preventScroll: true,
|
||||
});
|
||||
|
||||
const {
|
||||
modalState: videoTileSettingsModalState,
|
||||
modalProps: videoTileSettingsModalProps,
|
||||
} = useModalTriggerState();
|
||||
const onOptionsPress = () => {
|
||||
videoTileSettingsModalState.open();
|
||||
};
|
||||
|
||||
const onFullscreenCallback = useCallback(() => {
|
||||
onFullscreen(item);
|
||||
}, [onFullscreen, item]);
|
||||
|
||||
// Firefox doesn't respect the disablePictureInPicture attribute
|
||||
// https://bugzilla.mozilla.org/show_bug.cgi?id=1611831
|
||||
|
||||
return (
|
||||
<>
|
||||
<VideoTile
|
||||
isLocal={isLocal}
|
||||
speaking={speaking && !disableSpeakingIndicator}
|
||||
audioMuted={audioMuted}
|
||||
videoMuted={videoMuted}
|
||||
screenshare={purpose === SDPStreamMetadataPurpose.Screenshare}
|
||||
name={rawDisplayName}
|
||||
connectionState={item.connectionState}
|
||||
ref={tileRef}
|
||||
mediaRef={mediaRef}
|
||||
avatar={
|
||||
getAvatar && getAvatar(item.member, targetWidth, targetHeight)
|
||||
}
|
||||
onOptionsPress={onOptionsPress}
|
||||
localVolume={localVolume}
|
||||
hasAudio={hasAudio}
|
||||
maximised={maximised}
|
||||
fullscreen={fullscreen}
|
||||
onFullscreen={onFullscreenCallback}
|
||||
{...rest}
|
||||
/>
|
||||
{videoTileSettingsModalState.isOpen && !maximised && item.callFeed && (
|
||||
<VideoTileSettingsModal
|
||||
{...videoTileSettingsModalProps}
|
||||
feed={item.callFeed}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
);
|
||||
@@ -17,6 +17,7 @@ limitations under the License.
|
||||
import TinyQueue from "tinyqueue";
|
||||
|
||||
import { TileDescriptor } from "./TileDescriptor";
|
||||
import { count, findLastIndex } from "../array-utils";
|
||||
|
||||
/**
|
||||
* A 1×1 cell in a grid which belongs to a tile.
|
||||
@@ -105,17 +106,6 @@ export function getPaths(dest: number, g: Grid): (number | null)[] {
|
||||
return edges as (number | null)[];
|
||||
}
|
||||
|
||||
function findLastIndex<T>(
|
||||
array: T[],
|
||||
predicate: (item: T) => boolean
|
||||
): number | null {
|
||||
for (let i = array.length - 1; i >= 0; i--) {
|
||||
if (predicate(array[i])) return i;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const findLast1By1Index = (g: Grid): number | null =>
|
||||
findLastIndex(g.cells, (c) => c?.rows === 1 && c?.columns === 1);
|
||||
|
||||
@@ -185,6 +175,8 @@ const areaEnd = (
|
||||
g: Grid
|
||||
): number => start + columns - 1 + g.columns * (rows - 1);
|
||||
|
||||
const cloneGrid = (g: Grid): Grid => ({ ...g, cells: [...g.cells] });
|
||||
|
||||
/**
|
||||
* Gets the index of the next gap in the grid that should be backfilled by 1×1
|
||||
* tiles.
|
||||
@@ -209,11 +201,150 @@ function getNextGap(g: Grid): number | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the index of the origin of the tile to which the given cell belongs.
|
||||
*/
|
||||
function getOrigin(g: Grid, index: number): number {
|
||||
const initialColumn = column(index, g);
|
||||
|
||||
for (
|
||||
let i = index;
|
||||
i >= 0;
|
||||
i = column(i, g) === 0 ? i - g.columns + initialColumn : i - 1
|
||||
) {
|
||||
const cell = g.cells[i];
|
||||
if (
|
||||
cell !== undefined &&
|
||||
cell.origin &&
|
||||
inArea(index, i, areaEnd(i, cell.columns, cell.rows, g), g)
|
||||
)
|
||||
return i;
|
||||
}
|
||||
|
||||
throw new Error("Tile is broken");
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the tile at index "from" over to index "to", displacing other tiles
|
||||
* along the way.
|
||||
* Precondition: the destination area must consist of only 1×1 tiles.
|
||||
*/
|
||||
function moveTile(g: Grid, from: number, to: number) {
|
||||
const tile = g.cells[from]!;
|
||||
const fromEnd = areaEnd(from, tile.columns, tile.rows, g);
|
||||
const toEnd = areaEnd(to, tile.columns, tile.rows, g);
|
||||
|
||||
const displacedTiles: Cell[] = [];
|
||||
forEachCellInArea(to, toEnd, g, (c, i) => {
|
||||
if (c !== undefined && !inArea(i, from, fromEnd, g)) displacedTiles.push(c);
|
||||
});
|
||||
|
||||
const movingCells: Cell[] = [];
|
||||
forEachCellInArea(from, fromEnd, g, (c, i) => {
|
||||
movingCells.push(c!);
|
||||
g.cells[i] = undefined;
|
||||
});
|
||||
|
||||
forEachCellInArea(
|
||||
to,
|
||||
toEnd,
|
||||
g,
|
||||
(_c, i) => (g.cells[i] = movingCells.shift())
|
||||
);
|
||||
forEachCellInArea(
|
||||
from,
|
||||
fromEnd,
|
||||
g,
|
||||
(_c, i) => (g.cells[i] ??= displacedTiles.shift())
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the tile at index "from" over to index "to", if there is space.
|
||||
*/
|
||||
export function tryMoveTile(g: Grid, from: number, to: number): Grid {
|
||||
const tile = g.cells[from]!;
|
||||
|
||||
if (
|
||||
to >= 0 &&
|
||||
to < g.cells.length &&
|
||||
column(to, g) <= g.columns - tile.columns
|
||||
) {
|
||||
const fromEnd = areaEnd(from, tile.columns, tile.rows, g);
|
||||
const toEnd = areaEnd(to, tile.columns, tile.rows, g);
|
||||
|
||||
// The contents of a given cell are 'displaceable' if it's empty, holds a
|
||||
// 1×1 tile, or is part of the original tile we're trying to reposition
|
||||
const displaceable = (c: Cell | undefined, i: number): boolean =>
|
||||
c === undefined ||
|
||||
(c.columns === 1 && c.rows === 1) ||
|
||||
inArea(i, from, fromEnd, g);
|
||||
|
||||
if (allCellsInArea(to, toEnd, g, displaceable)) {
|
||||
// The target space is free; move
|
||||
const gClone = cloneGrid(g);
|
||||
moveTile(gClone, from, to);
|
||||
return gClone;
|
||||
}
|
||||
}
|
||||
|
||||
// The target space isn't free; don't move
|
||||
return g;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempts to push a tile upwards by one row, displacing 1×1 tiles and shifting
|
||||
* enlarged tiles around when necessary.
|
||||
* @returns Whether the tile was actually pushed
|
||||
*/
|
||||
function pushTileUp(g: Grid, from: number): boolean {
|
||||
const tile = g.cells[from]!;
|
||||
|
||||
// TODO: pushing large tiles sideways might be more successful in some
|
||||
// situations
|
||||
const cellsAboveAreDisplacable =
|
||||
from - g.columns >= 0 &&
|
||||
allCellsInArea(
|
||||
from - g.columns,
|
||||
from - g.columns + tile.columns - 1,
|
||||
g,
|
||||
(c, i) =>
|
||||
c === undefined ||
|
||||
(c.columns === 1 && c.rows === 1) ||
|
||||
pushTileUp(g, getOrigin(g, i))
|
||||
);
|
||||
|
||||
if (cellsAboveAreDisplacable) {
|
||||
moveTile(g, from, from - g.columns);
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Backfill any gaps in the grid.
|
||||
*/
|
||||
export function fillGaps(g: Grid): Grid {
|
||||
const result: Grid = { ...g, cells: [...g.cells] };
|
||||
const result = cloneGrid(g);
|
||||
|
||||
// This will hopefully be the size of the grid after we're done here, assuming
|
||||
// that we can pack the large tiles tightly enough
|
||||
const idealLength = count(result.cells, (c) => c !== undefined);
|
||||
|
||||
// Step 1: Take any large tiles hanging off the bottom of the grid, and push
|
||||
// them upwards
|
||||
for (let i = result.cells.length - 1; i >= idealLength; i--) {
|
||||
const cell = result.cells[i];
|
||||
if (cell !== undefined && (cell.columns > 1 || cell.rows > 1)) {
|
||||
const originIndex =
|
||||
i - (cell.columns - 1) - result.columns * (cell.rows - 1);
|
||||
// If it's not possible to pack the large tiles any tighter, give up
|
||||
if (!pushTileUp(result, originIndex)) break;
|
||||
}
|
||||
}
|
||||
|
||||
// Step 2: Fill all 1×1 gaps
|
||||
let gap = getNextGap(result);
|
||||
|
||||
if (gap !== null) {
|
||||
@@ -263,9 +394,6 @@ export function fillGaps(g: Grid): Grid {
|
||||
} while (gap !== null);
|
||||
}
|
||||
|
||||
// TODO: If there are any large tiles on the last row, shuffle them back
|
||||
// upwards into a full row
|
||||
|
||||
// Shrink the array to remove trailing gaps
|
||||
const finalLength =
|
||||
(findLastIndex(result.cells, (c) => c !== undefined) ?? -1) + 1;
|
||||
@@ -275,21 +403,104 @@ export function fillGaps(g: Grid): Grid {
|
||||
return result;
|
||||
}
|
||||
|
||||
export function appendItems(items: TileDescriptor[], g: Grid): Grid {
|
||||
return {
|
||||
...g,
|
||||
cells: [
|
||||
...g.cells,
|
||||
...items.map((i) => ({
|
||||
item: i,
|
||||
origin: true,
|
||||
columns: 1,
|
||||
rows: 1,
|
||||
})),
|
||||
],
|
||||
function createRows(g: Grid, count: number, atRow: number): Grid {
|
||||
const result = {
|
||||
columns: g.columns,
|
||||
cells: new Array(g.cells.length + g.columns * count),
|
||||
};
|
||||
const offsetAfterNewRows = g.columns * count;
|
||||
|
||||
// Copy tiles from the original grid to the new one, with the new rows
|
||||
// inserted at the target location
|
||||
g.cells.forEach((c, from) => {
|
||||
if (c?.origin) {
|
||||
const offset = row(from, g) >= atRow ? offsetAfterNewRows : 0;
|
||||
forEachCellInArea(
|
||||
from,
|
||||
areaEnd(from, c.columns, c.rows, g),
|
||||
g,
|
||||
(c, i) => {
|
||||
result.cells[i + offset] = c;
|
||||
}
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a set of new items into the grid.
|
||||
*/
|
||||
export function addItems(items: TileDescriptor[], g: Grid): Grid {
|
||||
let result = cloneGrid(g);
|
||||
|
||||
for (const item of items) {
|
||||
const cell = {
|
||||
item,
|
||||
origin: true,
|
||||
columns: 1,
|
||||
rows: 1,
|
||||
};
|
||||
|
||||
let placeAt: number;
|
||||
let hasGaps: boolean;
|
||||
|
||||
if (item.placeNear === undefined) {
|
||||
// This item has no special placement requests, so let's put it
|
||||
// uneventfully at the end of the grid
|
||||
placeAt = result.cells.length;
|
||||
hasGaps = false;
|
||||
} else {
|
||||
// This item wants to be placed near another; let's put it on a row
|
||||
// directly below the related tile
|
||||
const placeNear = result.cells.findIndex(
|
||||
(c) => c?.item.id === item.placeNear
|
||||
);
|
||||
if (placeNear === -1) {
|
||||
// Can't find the related tile, so let's give up and place it at the end
|
||||
placeAt = result.cells.length;
|
||||
hasGaps = false;
|
||||
} else {
|
||||
const placeNearCell = result.cells[placeNear]!;
|
||||
const placeNearEnd = areaEnd(
|
||||
placeNear,
|
||||
placeNearCell.columns,
|
||||
placeNearCell.rows,
|
||||
result
|
||||
);
|
||||
|
||||
result = createRows(result, 1, row(placeNearEnd, result) + 1);
|
||||
placeAt =
|
||||
placeNear +
|
||||
Math.floor(placeNearCell.columns / 2) +
|
||||
result.columns * placeNearCell.rows;
|
||||
hasGaps = true;
|
||||
}
|
||||
}
|
||||
|
||||
result.cells[placeAt] = cell;
|
||||
|
||||
if (item.largeBaseSize) {
|
||||
// Cycle the tile size once to set up the tile with its larger base size
|
||||
// This also fills any gaps in the grid, hence no extra call to fillGaps
|
||||
result = cycleTileSize(item.id, result);
|
||||
} else if (hasGaps) {
|
||||
result = fillGaps(result);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const largeTileDimensions = (g: Grid): [number, number] => [
|
||||
Math.min(3, Math.max(2, g.columns - 1)),
|
||||
2,
|
||||
];
|
||||
|
||||
const extraLargeTileDimensions = (g: Grid): [number, number] =>
|
||||
g.columns > 3 ? [4, 3] : [g.columns, 2];
|
||||
|
||||
/**
|
||||
* Changes the size of a tile, rearranging the grid to make space.
|
||||
* @param tileId The ID of the tile to modify.
|
||||
@@ -299,15 +510,19 @@ export function appendItems(items: TileDescriptor[], g: Grid): Grid {
|
||||
export function cycleTileSize(tileId: string, g: Grid): Grid {
|
||||
const from = g.cells.findIndex((c) => c?.item.id === tileId);
|
||||
if (from === -1) return g; // Tile removed, no change
|
||||
const fromWidth = g.cells[from]!.columns;
|
||||
const fromHeight = g.cells[from]!.rows;
|
||||
const fromCell = g.cells[from]!;
|
||||
const fromWidth = fromCell.columns;
|
||||
const fromHeight = fromCell.rows;
|
||||
const fromEnd = areaEnd(from, fromWidth, fromHeight, g);
|
||||
|
||||
// The target dimensions, which toggle between 1×1 and larger than 1×1
|
||||
const [baseDimensions, enlargedDimensions] = fromCell.item.largeBaseSize
|
||||
? [largeTileDimensions(g), extraLargeTileDimensions(g)]
|
||||
: [[1, 1], largeTileDimensions(g)];
|
||||
// The target dimensions, which toggle between the base and enlarged sizes
|
||||
const [toWidth, toHeight] =
|
||||
fromWidth === 1 && fromHeight === 1
|
||||
? [Math.min(3, Math.max(2, g.columns - 1)), 2]
|
||||
: [1, 1];
|
||||
fromWidth === baseDimensions[0] && fromHeight === baseDimensions[1]
|
||||
? enlargedDimensions
|
||||
: baseDimensions;
|
||||
|
||||
// If we're expanding the tile, we want to create enough new rows at the
|
||||
// tile's target position such that every new unit of grid area created during
|
||||
@@ -319,12 +534,6 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
|
||||
Math.ceil((toWidth * toHeight - fromWidth * fromHeight) / g.columns)
|
||||
);
|
||||
|
||||
// This is the grid with the new rows added
|
||||
const gappyGrid: Grid = {
|
||||
...g,
|
||||
cells: new Array(g.cells.length + newRows * g.columns),
|
||||
};
|
||||
|
||||
// The next task is to scan for a spot to place the modified tile. Since we
|
||||
// might be creating new rows at the target position, this spot can be shorter
|
||||
// than the target height.
|
||||
@@ -334,8 +543,8 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
|
||||
// To make the tile appear to expand outwards from its center, we're actually
|
||||
// scanning for locations to put the *center* of the tile. These numbers are
|
||||
// the offsets between the tile's origin and its center.
|
||||
const scanColumnOffset = Math.floor((toWidth - 1) / 2);
|
||||
const scanRowOffset = Math.floor((toHeight - 1) / 2);
|
||||
const scanColumnOffset = Math.floor((toWidth - fromWidth) / 2);
|
||||
const scanRowOffset = Math.floor((toHeight - fromHeight) / 2);
|
||||
|
||||
const nextScanLocations = new Set<number>([from]);
|
||||
const rows = row(g.cells.length - 1, g) + 1;
|
||||
@@ -379,17 +588,19 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
|
||||
|
||||
const toRow = row(to, g);
|
||||
|
||||
// Copy tiles from the original grid to the new one, with the new rows
|
||||
// inserted at the target location
|
||||
g.cells.forEach((c, src) => {
|
||||
if (c?.origin && c.item.id !== tileId) {
|
||||
const offset =
|
||||
row(src, g) > toRow + candidateHeight - 1 ? g.columns * newRows : 0;
|
||||
forEachCellInArea(src, areaEnd(src, c.columns, c.rows, g), g, (c, i) => {
|
||||
gappyGrid.cells[i + offset] = c;
|
||||
});
|
||||
}
|
||||
});
|
||||
// This is the grid with the new rows added
|
||||
const gappyGrid = createRows(g, newRows, toRow + candidateHeight);
|
||||
|
||||
// Remove the original tile
|
||||
const fromInGappyGrid =
|
||||
from + (row(from, g) >= toRow + candidateHeight ? g.columns * newRows : 0);
|
||||
const fromEndInGappyGrid = fromInGappyGrid - from + fromEnd;
|
||||
forEachCellInArea(
|
||||
fromInGappyGrid,
|
||||
fromEndInGappyGrid,
|
||||
gappyGrid,
|
||||
(_c, i) => (gappyGrid.cells[i] = undefined)
|
||||
);
|
||||
|
||||
// Place the tile in its target position, making a note of the tiles being
|
||||
// overwritten
|
||||
@@ -414,3 +625,79 @@ export function cycleTileSize(tileId: string, g: Grid): Grid {
|
||||
// Fill any gaps that remain
|
||||
return fillGaps(gappyGrid);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resizes the grid to a new column width.
|
||||
*/
|
||||
export function resize(g: Grid, columns: number): Grid {
|
||||
const result: Grid = { columns, cells: [] };
|
||||
const [largeColumns, largeRows] = largeTileDimensions(result);
|
||||
|
||||
// Copy each tile from the old grid to the resized one in the same order
|
||||
|
||||
// The next index in the result grid to copy a tile to
|
||||
let next = 0;
|
||||
|
||||
for (const cell of g.cells) {
|
||||
if (cell?.origin) {
|
||||
const [nextColumns, nextRows] =
|
||||
cell.columns > 1 || cell.rows > 1 ? [largeColumns, largeRows] : [1, 1];
|
||||
|
||||
// If there isn't enough space left on this row, jump to the next row
|
||||
if (columns - column(next, result) < nextColumns)
|
||||
next = columns * (Math.floor(next / columns) + 1);
|
||||
const nextEnd = areaEnd(next, nextColumns, nextRows, result);
|
||||
|
||||
// Expand the cells array as necessary
|
||||
if (result.cells.length <= nextEnd)
|
||||
result.cells.push(...new Array(nextEnd + 1 - result.cells.length));
|
||||
|
||||
// Copy the tile into place
|
||||
forEachCellInArea(next, nextEnd, result, (_c, i) => {
|
||||
result.cells[i] = {
|
||||
item: cell.item,
|
||||
origin: i === next,
|
||||
columns: nextColumns,
|
||||
rows: nextRows,
|
||||
};
|
||||
});
|
||||
|
||||
next = nextEnd + 1;
|
||||
}
|
||||
}
|
||||
|
||||
return fillGaps(result);
|
||||
}
|
||||
|
||||
/**
|
||||
* Promotes speakers to the first page of the grid.
|
||||
*/
|
||||
export function promoteSpeakers(g: Grid) {
|
||||
// This is all a bit of a hack right now, because we don't know if the designs
|
||||
// will stick with this approach in the long run
|
||||
// We assume that 4 rows are probably about 1 page
|
||||
const firstPageEnd = g.columns * 4;
|
||||
|
||||
for (let from = firstPageEnd; from < g.cells.length; from++) {
|
||||
const fromCell = g.cells[from];
|
||||
// Don't bother trying to promote enlarged tiles
|
||||
if (
|
||||
fromCell?.item.isSpeaker &&
|
||||
fromCell.columns === 1 &&
|
||||
fromCell.rows === 1
|
||||
) {
|
||||
// Promote this tile by making 10 attempts to place it on the first page
|
||||
for (let j = 0; j < 10; j++) {
|
||||
const to = Math.floor(Math.random() * firstPageEnd);
|
||||
const toCell = g.cells[to];
|
||||
if (
|
||||
toCell === undefined ||
|
||||
(toCell.columns === 1 && toCell.rows === 1)
|
||||
) {
|
||||
moveTile(g, from, to);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -15,13 +15,15 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import {
|
||||
appendItems,
|
||||
addItems,
|
||||
column,
|
||||
cycleTileSize,
|
||||
fillGaps,
|
||||
forEachCellInArea,
|
||||
Grid,
|
||||
resize,
|
||||
row,
|
||||
tryMoveTile,
|
||||
} from "../../src/video-grid/model";
|
||||
import { TileDescriptor } from "../../src/video-grid/TileDescriptor";
|
||||
|
||||
@@ -31,7 +33,7 @@ import { TileDescriptor } from "../../src/video-grid/TileDescriptor";
|
||||
function mkGrid(spec: string): Grid {
|
||||
const secondNewline = spec.indexOf("\n", 1);
|
||||
const columns = secondNewline === -1 ? spec.length : secondNewline - 1;
|
||||
const cells = spec.match(/[a-z ]/g) ?? [];
|
||||
const cells = spec.match(/[a-z ]/g) ?? ([] as string[]);
|
||||
const areas = new Set(cells);
|
||||
areas.delete(" "); // Space represents an empty cell, not an area
|
||||
const grid: Grid = { columns, cells: new Array(cells.length) };
|
||||
@@ -169,6 +171,50 @@ dddd
|
||||
iegh`
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"keeps a large tile from hanging off the bottom",
|
||||
`
|
||||
abcd
|
||||
efgh
|
||||
|
||||
ii
|
||||
ii`,
|
||||
`
|
||||
abcd
|
||||
iigh
|
||||
iief`
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"pushes a chain of large tiles upwards",
|
||||
`
|
||||
abcd
|
||||
e fg
|
||||
hh
|
||||
hh
|
||||
ii
|
||||
ii`,
|
||||
`
|
||||
hhcd
|
||||
hhfg
|
||||
aiib
|
||||
eii`
|
||||
);
|
||||
|
||||
testFillGaps(
|
||||
"gives up on pushing large tiles upwards when not possible",
|
||||
`
|
||||
aabb
|
||||
aabb
|
||||
cc
|
||||
cc`,
|
||||
`
|
||||
aabb
|
||||
aabb
|
||||
cc
|
||||
cc`
|
||||
);
|
||||
|
||||
function testCycleTileSize(
|
||||
title: string,
|
||||
tileId: string,
|
||||
@@ -227,9 +273,9 @@ dbbe
|
||||
fghi
|
||||
jk`,
|
||||
`
|
||||
abhc
|
||||
djge
|
||||
fik`
|
||||
akbc
|
||||
djhe
|
||||
fig`
|
||||
);
|
||||
|
||||
testCycleTileSize(
|
||||
@@ -267,17 +313,160 @@ dde
|
||||
ddf`
|
||||
);
|
||||
|
||||
test("appendItems appends 1×1 tiles", () => {
|
||||
const grid1 = `
|
||||
function testAddItems(
|
||||
title: string,
|
||||
items: TileDescriptor[],
|
||||
input: string,
|
||||
output: string
|
||||
): void {
|
||||
test(`addItems ${title}`, () => {
|
||||
expect(showGrid(addItems(items, mkGrid(input)))).toBe(output);
|
||||
});
|
||||
}
|
||||
|
||||
testAddItems(
|
||||
"appends 1×1 tiles",
|
||||
["e", "f"].map((i) => ({ id: i } as unknown as TileDescriptor)),
|
||||
`
|
||||
aab
|
||||
aac
|
||||
d`;
|
||||
const grid2 = `
|
||||
d`,
|
||||
`
|
||||
aab
|
||||
aac
|
||||
def`;
|
||||
const newItems = ["e", "f"].map(
|
||||
(i) => ({ id: i } as unknown as TileDescriptor)
|
||||
);
|
||||
expect(showGrid(appendItems(newItems, mkGrid(grid1)))).toBe(grid2);
|
||||
});
|
||||
def`
|
||||
);
|
||||
|
||||
testAddItems(
|
||||
"places one tile near another on request",
|
||||
[{ id: "g", placeNear: "b" } as unknown as TileDescriptor],
|
||||
`
|
||||
abc
|
||||
def`,
|
||||
`
|
||||
abc
|
||||
gfe
|
||||
d`
|
||||
);
|
||||
|
||||
testAddItems(
|
||||
"places items with a large base size",
|
||||
[{ id: "g", largeBaseSize: true } as unknown as TileDescriptor],
|
||||
`
|
||||
abc
|
||||
def`,
|
||||
`
|
||||
abc
|
||||
ggf
|
||||
gge
|
||||
d`
|
||||
);
|
||||
|
||||
function testTryMoveTile(
|
||||
title: string,
|
||||
from: number,
|
||||
to: number,
|
||||
input: string,
|
||||
output: string
|
||||
): void {
|
||||
test(`tryMoveTile ${title}`, () => {
|
||||
expect(showGrid(tryMoveTile(mkGrid(input), from, to))).toBe(output);
|
||||
});
|
||||
}
|
||||
|
||||
testTryMoveTile(
|
||||
"refuses to move a tile too far to the left",
|
||||
1,
|
||||
-1,
|
||||
`
|
||||
abc`,
|
||||
`
|
||||
abc`
|
||||
);
|
||||
|
||||
testTryMoveTile(
|
||||
"refuses to move a tile too far to the right",
|
||||
1,
|
||||
3,
|
||||
`
|
||||
abc`,
|
||||
`
|
||||
abc`
|
||||
);
|
||||
|
||||
testTryMoveTile(
|
||||
"moves a large tile to an unoccupied space",
|
||||
3,
|
||||
1,
|
||||
`
|
||||
a b
|
||||
ccd
|
||||
cce`,
|
||||
`
|
||||
acc
|
||||
bcc
|
||||
d e`
|
||||
);
|
||||
|
||||
testTryMoveTile(
|
||||
"refuses to move a large tile to an occupied space",
|
||||
3,
|
||||
1,
|
||||
`
|
||||
abb
|
||||
ccd
|
||||
cce`,
|
||||
`
|
||||
abb
|
||||
ccd
|
||||
cce`
|
||||
);
|
||||
|
||||
function testResize(
|
||||
title: string,
|
||||
columns: number,
|
||||
input: string,
|
||||
output: string
|
||||
): void {
|
||||
test(`resize ${title}`, () => {
|
||||
expect(showGrid(resize(mkGrid(input), columns))).toBe(output);
|
||||
});
|
||||
}
|
||||
|
||||
testResize(
|
||||
"contracts the grid",
|
||||
2,
|
||||
`
|
||||
abbb
|
||||
cbbb
|
||||
ddde
|
||||
dddf
|
||||
gh`,
|
||||
`
|
||||
af
|
||||
bb
|
||||
bb
|
||||
ch
|
||||
dd
|
||||
dd
|
||||
eg`
|
||||
);
|
||||
|
||||
testResize(
|
||||
"expands the grid",
|
||||
4,
|
||||
`
|
||||
af
|
||||
bb
|
||||
bb
|
||||
ch
|
||||
dd
|
||||
dd
|
||||
eg`,
|
||||
`
|
||||
bbbc
|
||||
bbbf
|
||||
addd
|
||||
hddd
|
||||
ge`
|
||||
);
|
||||
|
||||
16
yarn.lock
16
yarn.lock
@@ -1828,10 +1828,10 @@
|
||||
resolved "https://registry.yarnpkg.com/@juggle/resize-observer/-/resize-observer-3.3.1.tgz#b50a781709c81e10701004214340f25475a171a0"
|
||||
integrity sha512-zMM9Ds+SawiUkakS7y94Ymqx+S0ORzpG3frZirN3l+UlXUmSUR7hF4wxCVqW+ei94JzV5kt0uXBcoOEAuiydrw==
|
||||
|
||||
"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.9":
|
||||
version "0.1.0-alpha.9"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.9.tgz#00bc266781502641a661858a5a521dd4d95275fc"
|
||||
integrity sha512-g5cjpFwA9h0CbEGoAqNVI2QcyDsbI8FHoLo9+OXWHIezEKITsSv78mc5ilIwN+2YpmVlH0KNeQWTHw4vi0BMnw==
|
||||
"@matrix-org/matrix-sdk-crypto-js@^0.1.0-alpha.10":
|
||||
version "0.1.0-alpha.11"
|
||||
resolved "https://registry.yarnpkg.com/@matrix-org/matrix-sdk-crypto-js/-/matrix-sdk-crypto-js-0.1.0-alpha.11.tgz#24d705318c3159ef7dbe43bca464ac2bdd11e45d"
|
||||
integrity sha512-HD3rskPkqrUUSaKzGLg97k/bN+OZrkcX7ODB/pNBs/jqq+/A0wDKqsszJotzFwsQcDPpWn78BmMyvBo4tLxKjw==
|
||||
|
||||
"@matrix-org/olm@https://gitlab.matrix.org/api/v4/projects/27/packages/npm/@matrix-org/olm/-/@matrix-org/olm-3.2.14.tgz":
|
||||
version "3.2.14"
|
||||
@@ -10557,12 +10557,12 @@ matrix-events-sdk@0.0.1:
|
||||
resolved "https://registry.yarnpkg.com/matrix-events-sdk/-/matrix-events-sdk-0.0.1.tgz#c8c38911e2cb29023b0bbac8d6f32e0de2c957dd"
|
||||
integrity sha512-1QEOsXO+bhyCroIe2/A5OwaxHvBm7EsSQ46DEDn8RBIfQwN5HWBpFvyWWR4QY0KHPPnnJdI99wgRiAl7Ad5qaA==
|
||||
|
||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#3cfad3cdeb7b19b8e0e7015784efd803cb9542f1":
|
||||
version "26.0.0"
|
||||
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/3cfad3cdeb7b19b8e0e7015784efd803cb9542f1"
|
||||
"matrix-js-sdk@github:matrix-org/matrix-js-sdk#f884c78579c336a03bc20ff8f4e92c46582822b6":
|
||||
version "26.1.0"
|
||||
resolved "https://codeload.github.com/matrix-org/matrix-js-sdk/tar.gz/f884c78579c336a03bc20ff8f4e92c46582822b6"
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.12.5"
|
||||
"@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.9"
|
||||
"@matrix-org/matrix-sdk-crypto-js" "^0.1.0-alpha.10"
|
||||
another-json "^0.2.0"
|
||||
bs58 "^5.0.0"
|
||||
content-type "^1.0.4"
|
||||
|
||||
Reference in New Issue
Block a user