Compare commits

..

9 Commits

Author SHA1 Message Date
43ea9349f5 chore(main): release 0.10.0 (#616) 2025-09-11 20:57:19 -06:00
Igor Monadical
b3a8e9739d chore: whereby & s3 settings env error reporting (#637)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-11 17:52:34 -04:00
Igor Monadical
369ecdff13 feat: replace nextjs-config with environment variables (#632)
* chore: remove nextjs-config

* build fix

* update readme

* explicit nextjs env vars + remove feature-unrelated things and obsolete vars from config

* full config removal

* remove force-dynamic from pages

* compile fix

* restore claude-deleted tests

* better .env.example

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-11 11:20:41 -04:00
fc363bd49b fix: missing follow_redirects=True on modal endpoint (#630) 2025-09-10 08:15:47 -06:00
Igor Monadical
962038ee3f fix: auth post (#627)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-09 16:46:57 -04:00
Igor Monadical
3b85ff3bdf fix: auth post (#626)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-09 16:27:46 -04:00
Igor Monadical
cde99ca271 fix: auth post (#624)
Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-09 15:48:07 -04:00
Igor Monadical
f81fe9948a fix: anonymous users transcript permissions (#621)
* fix: public transcript visibility

* fix: transcript permissions frontend

* dead code removal

* chore: remove unused code

* fix search tests

* fix search tests

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-09 10:50:29 -04:00
Igor Monadical
5a5b323382 fix: sync backend and frontend token refresh logic (#614)
* sync backend and frontend token refresh logic

* return react strict mode

---------

Co-authored-by: Igor Loskutov <igor.loskutoff@gmail.com>
2025-09-08 10:40:18 -04:00
51 changed files with 973 additions and 2354 deletions

View File

@@ -1,5 +1,22 @@
# Changelog # Changelog
## [0.10.0](https://github.com/Monadical-SAS/reflector/compare/v0.9.0...v0.10.0) (2025-09-11)
### Features
* replace nextjs-config with environment variables ([#632](https://github.com/Monadical-SAS/reflector/issues/632)) ([369ecdf](https://github.com/Monadical-SAS/reflector/commit/369ecdff13f3862d926a9c0b87df52c9d94c4dde))
### Bug Fixes
* anonymous users transcript permissions ([#621](https://github.com/Monadical-SAS/reflector/issues/621)) ([f81fe99](https://github.com/Monadical-SAS/reflector/commit/f81fe9948a9237b3e0001b2d8ca84f54d76878f9))
* auth post ([#624](https://github.com/Monadical-SAS/reflector/issues/624)) ([cde99ca](https://github.com/Monadical-SAS/reflector/commit/cde99ca2716f84ba26798f289047732f0448742e))
* auth post ([#626](https://github.com/Monadical-SAS/reflector/issues/626)) ([3b85ff3](https://github.com/Monadical-SAS/reflector/commit/3b85ff3bdf4fb053b103070646811bc990c0e70a))
* auth post ([#627](https://github.com/Monadical-SAS/reflector/issues/627)) ([962038e](https://github.com/Monadical-SAS/reflector/commit/962038ee3f2a555dc3c03856be0e4409456e0996))
* missing follow_redirects=True on modal endpoint ([#630](https://github.com/Monadical-SAS/reflector/issues/630)) ([fc363bd](https://github.com/Monadical-SAS/reflector/commit/fc363bd49b17b075e64f9186e5e0185abc325ea7))
* sync backend and frontend token refresh logic ([#614](https://github.com/Monadical-SAS/reflector/issues/614)) ([5a5b323](https://github.com/Monadical-SAS/reflector/commit/5a5b3233820df9536da75e87ce6184a983d4713a))
## [0.9.0](https://github.com/Monadical-SAS/reflector/compare/v0.8.2...v0.9.0) (2025-09-06) ## [0.9.0](https://github.com/Monadical-SAS/reflector/compare/v0.8.2...v0.9.0) (2025-09-06)

View File

@@ -66,7 +66,6 @@ pnpm install
# Copy configuration templates # Copy configuration templates
cp .env_template .env cp .env_template .env
cp config-template.ts config.ts
``` ```
**Development:** **Development:**

View File

@@ -99,11 +99,10 @@ Start with `cd www`.
```bash ```bash
pnpm install pnpm install
cp .env_template .env cp .env.example .env
cp config-template.ts config.ts
``` ```
Then, fill in the environment variables in `.env` and the configuration in `config.ts` as needed. If you are unsure on how to proceed, ask in Zulip. Then, fill in the environment variables in `.env` as needed. If you are unsure on how to proceed, ask in Zulip.
**Run in development mode** **Run in development mode**
@@ -168,3 +167,34 @@ You can manually process an audio file by calling the process tool:
```bash ```bash
uv run python -m reflector.tools.process path/to/audio.wav uv run python -m reflector.tools.process path/to/audio.wav
``` ```
## Feature Flags
Reflector uses environment variable-based feature flags to control application functionality. These flags allow you to enable or disable features without code changes.
### Available Feature Flags
| Feature Flag | Environment Variable |
|-------------|---------------------|
| `requireLogin` | `NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN` |
| `privacy` | `NEXT_PUBLIC_FEATURE_PRIVACY` |
| `browse` | `NEXT_PUBLIC_FEATURE_BROWSE` |
| `sendToZulip` | `NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP` |
| `rooms` | `NEXT_PUBLIC_FEATURE_ROOMS` |
### Setting Feature Flags
Feature flags are controlled via environment variables using the pattern `NEXT_PUBLIC_FEATURE_{FEATURE_NAME}` where `{FEATURE_NAME}` is the SCREAMING_SNAKE_CASE version of the feature name.
**Examples:**
```bash
# Enable user authentication requirement
NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
# Disable browse functionality
NEXT_PUBLIC_FEATURE_BROWSE=false
# Enable Zulip integration
NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
```

View File

@@ -2,7 +2,6 @@ from datetime import datetime
from typing import Literal from typing import Literal
import sqlalchemy as sa import sqlalchemy as sa
from fastapi import HTTPException
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from reflector.db import get_database, metadata from reflector.db import get_database, metadata
@@ -178,23 +177,6 @@ class MeetingController:
return None return None
return Meeting(**result) return Meeting(**result)
async def get_by_id_for_http(self, meeting_id: str, user_id: str | None) -> Meeting:
"""
Get a meeting by ID for HTTP request.
If not found, it will raise a 404 error.
"""
query = meetings.select().where(meetings.c.id == meeting_id)
result = await get_database().fetch_one(query)
if not result:
raise HTTPException(status_code=404, detail="Meeting not found")
meeting = Meeting(**result)
if result["user_id"] != user_id:
meeting.host_room_url = ""
return meeting
async def update_meeting(self, meeting_id: str, **kwargs): async def update_meeting(self, meeting_id: str, **kwargs):
query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs) query = meetings.update().where(meetings.c.id == meeting_id).values(**kwargs)
await get_database().execute(query) await get_database().execute(query)

View File

@@ -23,7 +23,7 @@ from pydantic import (
from reflector.db import get_database from reflector.db import get_database
from reflector.db.rooms import rooms from reflector.db.rooms import rooms
from reflector.db.transcripts import SourceKind, transcripts from reflector.db.transcripts import SourceKind, TranscriptStatus, transcripts
from reflector.db.utils import is_postgresql from reflector.db.utils import is_postgresql
from reflector.logger import logger from reflector.logger import logger
from reflector.utils.string import NonEmptyString, try_parse_non_empty_string from reflector.utils.string import NonEmptyString, try_parse_non_empty_string
@@ -161,7 +161,7 @@ class SearchResult(BaseModel):
room_name: str | None = None room_name: str | None = None
source_kind: SourceKind source_kind: SourceKind
created_at: datetime created_at: datetime
status: str = Field(..., min_length=1) status: TranscriptStatus = Field(..., min_length=1)
rank: float = Field(..., ge=0, le=1) rank: float = Field(..., ge=0, le=1)
duration: NonNegativeFloat | None = Field(..., description="Duration in seconds") duration: NonNegativeFloat | None = Field(..., description="Duration in seconds")
search_snippets: list[str] = Field( search_snippets: list[str] = Field(

View File

@@ -47,6 +47,7 @@ class FileDiarizationModalProcessor(FileDiarizationProcessor):
"audio_file_url": data.audio_url, "audio_file_url": data.audio_url,
"timestamp": 0, "timestamp": 0,
}, },
follow_redirects=True,
) )
response.raise_for_status() response.raise_for_status()
diarization_data = response.json()["diarization"] diarization_data = response.json()["diarization"]

View File

@@ -54,6 +54,7 @@ class FileTranscriptModalProcessor(FileTranscriptProcessor):
"language": data.language, "language": data.language,
"batch": True, "batch": True,
}, },
follow_redirects=True,
) )
response.raise_for_status() response.raise_for_status()
result = response.json() result = response.json()

View File

@@ -1,6 +1,8 @@
from pydantic.types import PositiveInt from pydantic.types import PositiveInt
from pydantic_settings import BaseSettings, SettingsConfigDict from pydantic_settings import BaseSettings, SettingsConfigDict
from reflector.utils.string import NonEmptyString
class Settings(BaseSettings): class Settings(BaseSettings):
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
@@ -120,7 +122,7 @@ class Settings(BaseSettings):
# Whereby integration # Whereby integration
WHEREBY_API_URL: str = "https://api.whereby.dev/v1" WHEREBY_API_URL: str = "https://api.whereby.dev/v1"
WHEREBY_API_KEY: str | None = None WHEREBY_API_KEY: NonEmptyString | None = None
WHEREBY_WEBHOOK_SECRET: str | None = None WHEREBY_WEBHOOK_SECRET: str | None = None
AWS_WHEREBY_ACCESS_KEY_ID: str | None = None AWS_WHEREBY_ACCESS_KEY_ID: str | None = None
AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None AWS_WHEREBY_ACCESS_KEY_SECRET: str | None = None

View File

@@ -10,8 +10,11 @@ NonEmptyString = Annotated[
non_empty_string_adapter = TypeAdapter(NonEmptyString) non_empty_string_adapter = TypeAdapter(NonEmptyString)
def parse_non_empty_string(s: str) -> NonEmptyString: def parse_non_empty_string(s: str, error: str | None = None) -> NonEmptyString:
return non_empty_string_adapter.validate_python(s) try:
return non_empty_string_adapter.validate_python(s)
except Exception as e:
raise ValueError(f"{e}: {error}" if error else e) from e
def try_parse_non_empty_string(s: str) -> NonEmptyString | None: def try_parse_non_empty_string(s: str) -> NonEmptyString | None:

View File

@@ -215,14 +215,10 @@ async def rooms_create_meeting(
except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError): except (asyncpg.exceptions.UniqueViolationError, sqlite3.IntegrityError):
# Another request already created a meeting for this room # Another request already created a meeting for this room
# Log this race condition occurrence # Log this race condition occurrence
logger.info(
"Race condition detected for room %s - fetching existing meeting",
room.name,
)
logger.warning( logger.warning(
"Whereby meeting %s was created but not used (resource leak) for room %s", "Race condition detected for room %s and meeting %s - fetching existing meeting",
whereby_meeting["meetingId"],
room.name, room.name,
whereby_meeting["meetingId"],
) )
# Fetch the meeting that was created by the other request # Fetch the meeting that was created by the other request
@@ -232,7 +228,9 @@ async def rooms_create_meeting(
if meeting is None: if meeting is None:
# Edge case: meeting was created but expired/deleted between checks # Edge case: meeting was created but expired/deleted between checks
logger.error( logger.error(
"Meeting disappeared after race condition for room %s", room.name "Meeting disappeared after race condition for room %s",
room.name,
exc_info=True,
) )
raise HTTPException( raise HTTPException(
status_code=503, detail="Unable to join meeting - please try again" status_code=503, detail="Unable to join meeting - please try again"

View File

@@ -350,8 +350,6 @@ async def transcript_update(
transcript = await transcripts_controller.get_by_id_for_http( transcript = await transcripts_controller.get_by_id_for_http(
transcript_id, user_id=user_id transcript_id, user_id=user_id
) )
if not transcript:
raise HTTPException(status_code=404, detail="Transcript not found")
values = info.dict(exclude_unset=True) values = info.dict(exclude_unset=True)
updated_transcript = await transcripts_controller.update(transcript, values) updated_transcript = await transcripts_controller.update(transcript, values)
return updated_transcript return updated_transcript

View File

@@ -1,18 +1,60 @@
import logging
from datetime import datetime from datetime import datetime
import httpx import httpx
from reflector.db.rooms import Room from reflector.db.rooms import Room
from reflector.settings import settings from reflector.settings import settings
from reflector.utils.string import parse_non_empty_string
logger = logging.getLogger(__name__)
def _get_headers():
api_key = parse_non_empty_string(
settings.WHEREBY_API_KEY, "WHEREBY_API_KEY value is required."
)
return {
"Content-Type": "application/json; charset=utf-8",
"Authorization": f"Bearer {api_key}",
}
HEADERS = {
"Content-Type": "application/json; charset=utf-8",
"Authorization": f"Bearer {settings.WHEREBY_API_KEY}",
}
TIMEOUT = 10 # seconds TIMEOUT = 10 # seconds
def _get_whereby_s3_auth():
errors = []
try:
bucket_name = parse_non_empty_string(
settings.RECORDING_STORAGE_AWS_BUCKET_NAME,
"RECORDING_STORAGE_AWS_BUCKET_NAME value is required.",
)
except Exception as e:
errors.append(e)
try:
key_id = parse_non_empty_string(
settings.AWS_WHEREBY_ACCESS_KEY_ID,
"AWS_WHEREBY_ACCESS_KEY_ID value is required.",
)
except Exception as e:
errors.append(e)
try:
key_secret = parse_non_empty_string(
settings.AWS_WHEREBY_ACCESS_KEY_SECRET,
"AWS_WHEREBY_ACCESS_KEY_SECRET value is required.",
)
except Exception as e:
errors.append(e)
if len(errors) > 0:
raise Exception(
f"Failed to get Whereby auth settings: {', '.join(str(e) for e in errors)}"
)
return bucket_name, key_id, key_secret
async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room): async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
s3_bucket_name, s3_key_id, s3_key_secret = _get_whereby_s3_auth()
data = { data = {
"isLocked": room.is_locked, "isLocked": room.is_locked,
"roomNamePrefix": room_name_prefix, "roomNamePrefix": room_name_prefix,
@@ -23,23 +65,26 @@ async def create_meeting(room_name_prefix: str, end_date: datetime, room: Room):
"type": room.recording_type, "type": room.recording_type,
"destination": { "destination": {
"provider": "s3", "provider": "s3",
"bucket": settings.RECORDING_STORAGE_AWS_BUCKET_NAME, "bucket": s3_bucket_name,
"accessKeyId": settings.AWS_WHEREBY_ACCESS_KEY_ID, "accessKeyId": s3_key_id,
"accessKeySecret": settings.AWS_WHEREBY_ACCESS_KEY_SECRET, "accessKeySecret": s3_key_secret,
"fileFormat": "mp4", "fileFormat": "mp4",
}, },
"startTrigger": room.recording_trigger, "startTrigger": room.recording_trigger,
}, },
"fields": ["hostRoomUrl"], "fields": ["hostRoomUrl"],
} }
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.post( response = await client.post(
f"{settings.WHEREBY_API_URL}/meetings", f"{settings.WHEREBY_API_URL}/meetings",
headers=HEADERS, headers=_get_headers(),
json=data, json=data,
timeout=TIMEOUT, timeout=TIMEOUT,
) )
if response.status_code == 403:
logger.warning(
f"Failed to create meeting: access denied on Whereby: {response.text}"
)
response.raise_for_status() response.raise_for_status()
return response.json() return response.json()
@@ -48,7 +93,7 @@ async def get_room_sessions(room_name: str):
async with httpx.AsyncClient() as client: async with httpx.AsyncClient() as client:
response = await client.get( response = await client.get(
f"{settings.WHEREBY_API_URL}/insights/room-sessions?roomName={room_name}", f"{settings.WHEREBY_API_URL}/insights/room-sessions?roomName={room_name}",
headers=HEADERS, headers=_get_headers(),
timeout=TIMEOUT, timeout=TIMEOUT,
) )
response.raise_for_status() response.raise_for_status()

View File

@@ -58,7 +58,7 @@ async def test_empty_transcript_title_only_match():
"id": test_id, "id": test_id,
"name": "Empty Transcript", "name": "Empty Transcript",
"title": "Empty Meeting", "title": "Empty Meeting",
"status": "completed", "status": "ended",
"locked": False, "locked": False,
"duration": 0.0, "duration": 0.0,
"created_at": datetime.now(timezone.utc), "created_at": datetime.now(timezone.utc),
@@ -109,7 +109,7 @@ async def test_search_with_long_summary():
"id": test_id, "id": test_id,
"name": "Test Long Summary", "name": "Test Long Summary",
"title": "Regular Meeting", "title": "Regular Meeting",
"status": "completed", "status": "ended",
"locked": False, "locked": False,
"duration": 1800.0, "duration": 1800.0,
"created_at": datetime.now(timezone.utc), "created_at": datetime.now(timezone.utc),
@@ -165,7 +165,7 @@ async def test_postgresql_search_with_data():
"id": test_id, "id": test_id,
"name": "Test Search Transcript", "name": "Test Search Transcript",
"title": "Engineering Planning Meeting Q4 2024", "title": "Engineering Planning Meeting Q4 2024",
"status": "completed", "status": "ended",
"locked": False, "locked": False,
"duration": 1800.0, "duration": 1800.0,
"created_at": datetime.now(timezone.utc), "created_at": datetime.now(timezone.utc),
@@ -221,7 +221,7 @@ We need to implement PostgreSQL tsvector for better performance.""",
test_result = next((r for r in results if r.id == test_id), None) test_result = next((r for r in results if r.id == test_id), None)
if test_result: if test_result:
assert test_result.title == "Engineering Planning Meeting Q4 2024" assert test_result.title == "Engineering Planning Meeting Q4 2024"
assert test_result.status == "completed" assert test_result.status == "ended"
assert test_result.duration == 1800.0 assert test_result.duration == 1800.0
assert 0 <= test_result.rank <= 1, "Rank should be normalized to 0-1" assert 0 <= test_result.rank <= 1, "Rank should be normalized to 0-1"
@@ -268,7 +268,7 @@ def mock_db_result():
"title": "Test Transcript", "title": "Test Transcript",
"created_at": datetime(2024, 6, 15, tzinfo=timezone.utc), "created_at": datetime(2024, 6, 15, tzinfo=timezone.utc),
"duration": 3600.0, "duration": 3600.0,
"status": "completed", "status": "ended",
"user_id": "test-user", "user_id": "test-user",
"room_id": "room1", "room_id": "room1",
"source_kind": SourceKind.LIVE, "source_kind": SourceKind.LIVE,
@@ -433,7 +433,7 @@ class TestSearchResultModel:
room_id="room-456", room_id="room-456",
source_kind=SourceKind.ROOM, source_kind=SourceKind.ROOM,
created_at=datetime(2024, 6, 15, tzinfo=timezone.utc), created_at=datetime(2024, 6, 15, tzinfo=timezone.utc),
status="completed", status="ended",
rank=0.85, rank=0.85,
duration=1800.5, duration=1800.5,
search_snippets=["snippet 1", "snippet 2"], search_snippets=["snippet 1", "snippet 2"],
@@ -443,7 +443,7 @@ class TestSearchResultModel:
assert result.title == "Test Title" assert result.title == "Test Title"
assert result.user_id == "user-123" assert result.user_id == "user-123"
assert result.room_id == "room-456" assert result.room_id == "room-456"
assert result.status == "completed" assert result.status == "ended"
assert result.rank == 0.85 assert result.rank == 0.85
assert result.duration == 1800.5 assert result.duration == 1800.5
assert len(result.search_snippets) == 2 assert len(result.search_snippets) == 2
@@ -474,7 +474,7 @@ class TestSearchResultModel:
id="test-id", id="test-id",
source_kind=SourceKind.LIVE, source_kind=SourceKind.LIVE,
created_at=datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc), created_at=datetime(2024, 6, 15, 12, 30, 45, tzinfo=timezone.utc),
status="completed", status="ended",
rank=0.9, rank=0.9,
duration=None, duration=None,
search_snippets=[], search_snippets=[],

View File

@@ -25,7 +25,7 @@ async def test_long_summary_snippet_prioritization():
"id": test_id, "id": test_id,
"name": "Test Snippet Priority", "name": "Test Snippet Priority",
"title": "Meeting About Projects", "title": "Meeting About Projects",
"status": "completed", "status": "ended",
"locked": False, "locked": False,
"duration": 1800.0, "duration": 1800.0,
"created_at": datetime.now(timezone.utc), "created_at": datetime.now(timezone.utc),
@@ -106,7 +106,7 @@ async def test_long_summary_only_search():
"id": test_id, "id": test_id,
"name": "Test Long Only", "name": "Test Long Only",
"title": "Standard Meeting", "title": "Standard Meeting",
"status": "completed", "status": "ended",
"locked": False, "locked": False,
"duration": 1800.0, "duration": 1800.0,
"created_at": datetime.now(timezone.utc), "created_at": datetime.now(timezone.utc),

34
www/.env.example Normal file
View File

@@ -0,0 +1,34 @@
# Environment
ENVIRONMENT=development
NEXT_PUBLIC_ENV=development
# Site Configuration
NEXT_PUBLIC_SITE_URL=http://localhost:3000
# Nextauth envs
# not used in app code but in lib code
NEXTAUTH_URL=http://localhost:3000
NEXTAUTH_SECRET=your-nextauth-secret-here
# / Nextauth envs
# Authentication (Authentik OAuth/OIDC)
AUTHENTIK_ISSUER=https://authentik.example.com/application/o/reflector
AUTHENTIK_REFRESH_TOKEN_URL=https://authentik.example.com/application/o/token/
AUTHENTIK_CLIENT_ID=your-client-id-here
AUTHENTIK_CLIENT_SECRET=your-client-secret-here
# Feature Flags
# NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN=true
# NEXT_PUBLIC_FEATURE_PRIVACY=false
# NEXT_PUBLIC_FEATURE_BROWSE=true
# NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP=true
# NEXT_PUBLIC_FEATURE_ROOMS=true
# API URLs
NEXT_PUBLIC_API_URL=http://127.0.0.1:1250
NEXT_PUBLIC_WEBSOCKET_URL=ws://127.0.0.1:1250
NEXT_PUBLIC_AUTH_CALLBACK_URL=http://localhost:3000/auth-callback
# Sentry
# SENTRY_DSN=https://your-dsn@sentry.io/project-id
# SENTRY_IGNORE_API_RESOLUTION_ERROR=1

1
www/.gitignore vendored
View File

@@ -40,7 +40,6 @@ next-env.d.ts
# Sentry Auth Token # Sentry Auth Token
.sentryclirc .sentryclirc
config.ts
# openapi logs # openapi logs
openapi-ts-error-*.log openapi-ts-error-*.log

View File

@@ -7,9 +7,10 @@ import {
FaMicrophone, FaMicrophone,
FaGear, FaGear,
} from "react-icons/fa6"; } from "react-icons/fa6";
import { TranscriptStatus } from "../../../lib/transcript";
interface TranscriptStatusIconProps { interface TranscriptStatusIconProps {
status: string; status: TranscriptStatus;
} }
export default function TranscriptStatusIcon({ export default function TranscriptStatusIcon({

View File

@@ -1,5 +1,5 @@
import { Container, Flex, Link } from "@chakra-ui/react"; import { Container, Flex, Link } from "@chakra-ui/react";
import { getConfig } from "../lib/edgeConfig"; import { featureEnabled } from "../lib/features";
import NextLink from "next/link"; import NextLink from "next/link";
import Image from "next/image"; import Image from "next/image";
import UserInfo from "../(auth)/userInfo"; import UserInfo from "../(auth)/userInfo";
@@ -11,8 +11,6 @@ export default async function AppLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const config = await getConfig();
const { requireLogin, privacy, browse, rooms } = config.features;
return ( return (
<Container <Container
minW="100vw" minW="100vw"
@@ -58,7 +56,7 @@ export default async function AppLayout({
> >
Create Create
</Link> </Link>
{browse ? ( {featureEnabled("browse") ? (
<> <>
&nbsp;·&nbsp; &nbsp;·&nbsp;
<Link href="/browse" as={NextLink} className="font-light px-2"> <Link href="/browse" as={NextLink} className="font-light px-2">
@@ -68,7 +66,7 @@ export default async function AppLayout({
) : ( ) : (
<></> <></>
)} )}
{rooms ? ( {featureEnabled("rooms") ? (
<> <>
&nbsp;·&nbsp; &nbsp;·&nbsp;
<Link href="/rooms" as={NextLink} className="font-light px-2"> <Link href="/rooms" as={NextLink} className="font-light px-2">
@@ -78,7 +76,7 @@ export default async function AppLayout({
) : ( ) : (
<></> <></>
)} )}
{requireLogin ? ( {featureEnabled("requireLogin") ? (
<> <>
&nbsp;·&nbsp; &nbsp;·&nbsp;
<UserInfo /> <UserInfo />

View File

@@ -3,8 +3,10 @@ import ScrollToBottom from "../../scrollToBottom";
import { Topic } from "../../webSocketTypes"; import { Topic } from "../../webSocketTypes";
import useParticipants from "../../useParticipants"; import useParticipants from "../../useParticipants";
import { Box, Flex, Text, Accordion } from "@chakra-ui/react"; import { Box, Flex, Text, Accordion } from "@chakra-ui/react";
import { featureEnabled } from "../../../../domainContext";
import { TopicItem } from "./TopicItem"; import { TopicItem } from "./TopicItem";
import { TranscriptStatus } from "../../../../lib/transcript";
import { featureEnabled } from "../../../../lib/features";
type TopicListProps = { type TopicListProps = {
topics: Topic[]; topics: Topic[];
@@ -14,7 +16,7 @@ type TopicListProps = {
]; ];
autoscroll: boolean; autoscroll: boolean;
transcriptId: string; transcriptId: string;
status: string; status: TranscriptStatus | null;
currentTranscriptText: any; currentTranscriptText: any;
}; };

View File

@@ -9,8 +9,10 @@ import ParticipantList from "./participantList";
import type { components } from "../../../../reflector-api"; import type { components } from "../../../../reflector-api";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
import { SelectedText, selectedTextIsTimeSlice } from "./types"; import { SelectedText, selectedTextIsTimeSlice } from "./types";
import { useTranscriptUpdate } from "../../../../lib/apiHooks"; import {
import useTranscript from "../../useTranscript"; useTranscriptGet,
useTranscriptUpdate,
} from "../../../../lib/apiHooks";
import { useError } from "../../../../(errors)/errorContext"; import { useError } from "../../../../(errors)/errorContext";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Box, Grid } from "@chakra-ui/react"; import { Box, Grid } from "@chakra-ui/react";
@@ -25,7 +27,7 @@ export default function TranscriptCorrect({
params: { transcriptId }, params: { transcriptId },
}: TranscriptCorrect) { }: TranscriptCorrect) {
const updateTranscriptMutation = useTranscriptUpdate(); const updateTranscriptMutation = useTranscriptUpdate();
const transcript = useTranscript(transcriptId); const transcript = useTranscriptGet(transcriptId);
const stateCurrentTopic = useState<GetTranscriptTopic>(); const stateCurrentTopic = useState<GetTranscriptTopic>();
const [currentTopic, _sct] = stateCurrentTopic; const [currentTopic, _sct] = stateCurrentTopic;
const stateSelectedText = useState<SelectedText>(); const stateSelectedText = useState<SelectedText>();
@@ -36,7 +38,7 @@ export default function TranscriptCorrect({
const router = useRouter(); const router = useRouter();
const markAsDone = async () => { const markAsDone = async () => {
if (transcript.response && !transcript.response.reviewed) { if (transcript.data && !transcript.data.reviewed) {
try { try {
await updateTranscriptMutation.mutateAsync({ await updateTranscriptMutation.mutateAsync({
params: { params: {
@@ -114,7 +116,7 @@ export default function TranscriptCorrect({
}} }}
/> />
</Grid> </Grid>
{transcript.response && !transcript.response?.reviewed && ( {transcript.data && !transcript.data?.reviewed && (
<div className="flex flex-row justify-end"> <div className="flex flex-row justify-end">
<button <button
className="p-2 px-4 rounded bg-green-400" className="p-2 px-4 rounded bg-green-400"

View File

@@ -1,6 +1,5 @@
"use client"; "use client";
import Modal from "../modal"; import Modal from "../modal";
import useTranscript from "../useTranscript";
import useTopics from "../useTopics"; import useTopics from "../useTopics";
import useWaveform from "../useWaveform"; import useWaveform from "../useWaveform";
import useMp3 from "../useMp3"; import useMp3 from "../useMp3";
@@ -12,6 +11,8 @@ import TranscriptTitle from "../transcriptTitle";
import Player from "../player"; import Player from "../player";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import { Box, Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react"; import { Box, Flex, Grid, GridItem, Skeleton, Text } from "@chakra-ui/react";
import { useTranscriptGet } from "../../../lib/apiHooks";
import { TranscriptStatus } from "../../../lib/transcript";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -22,11 +23,15 @@ type TranscriptDetails = {
export default function TranscriptDetails(details: TranscriptDetails) { export default function TranscriptDetails(details: TranscriptDetails) {
const transcriptId = details.params.transcriptId; const transcriptId = details.params.transcriptId;
const router = useRouter(); const router = useRouter();
const statusToRedirect = ["idle", "recording", "processing"]; const statusToRedirect = [
"idle",
"recording",
"processing",
] satisfies TranscriptStatus[] as TranscriptStatus[];
const transcript = useTranscript(transcriptId); const transcript = useTranscriptGet(transcriptId);
const transcriptStatus = transcript.response?.status; const waiting =
const waiting = statusToRedirect.includes(transcriptStatus || ""); transcript.data && statusToRedirect.includes(transcript.data.status);
const mp3 = useMp3(transcriptId, waiting); const mp3 = useMp3(transcriptId, waiting);
const topics = useTopics(transcriptId); const topics = useTopics(transcriptId);
@@ -56,7 +61,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
); );
} }
if (transcript?.loading || topics?.loading) { if (transcript?.isLoading || topics?.loading) {
return <Modal title="Loading" text={"Loading transcript..."} />; return <Modal title="Loading" text={"Loading transcript..."} />;
} }
@@ -86,7 +91,7 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
waveform={waveform.waveform} waveform={waveform.waveform}
media={mp3.media} media={mp3.media}
mediaDuration={transcript.response?.duration || null} mediaDuration={transcript.data?.duration || null}
/> />
) : !mp3.loading && (waveform.error || mp3.error) ? ( ) : !mp3.loading && (waveform.error || mp3.error) ? (
<Box p={4} bg="red.100" borderRadius="md"> <Box p={4} bg="red.100" borderRadius="md">
@@ -116,10 +121,10 @@ export default function TranscriptDetails(details: TranscriptDetails) {
<Flex direction="column" gap={0}> <Flex direction="column" gap={0}>
<Flex alignItems="center" gap={2}> <Flex alignItems="center" gap={2}>
<TranscriptTitle <TranscriptTitle
title={transcript.response?.title || "Unnamed Transcript"} title={transcript.data?.title || "Unnamed Transcript"}
transcriptId={transcriptId} transcriptId={transcriptId}
onUpdate={(newTitle) => { onUpdate={(newTitle) => {
transcript.reload(); transcript.refetch().then(() => {});
}} }}
/> />
</Flex> </Flex>
@@ -136,23 +141,23 @@ export default function TranscriptDetails(details: TranscriptDetails) {
useActiveTopic={useActiveTopic} useActiveTopic={useActiveTopic}
autoscroll={false} autoscroll={false}
transcriptId={transcriptId} transcriptId={transcriptId}
status={transcript.response?.status} status={transcript.data?.status || null}
currentTranscriptText="" currentTranscriptText=""
/> />
{transcript.response && topics.topics ? ( {transcript.data && topics.topics ? (
<> <>
<FinalSummary <FinalSummary
transcriptResponse={transcript.response} transcriptResponse={transcript.data}
topicsResponse={topics.topics} topicsResponse={topics.topics}
onUpdate={(newSummary) => { onUpdate={() => {
transcript.reload(); transcript.refetch();
}} }}
/> />
</> </>
) : ( ) : (
<Flex justify={"center"} alignItems={"center"} h={"100%"}> <Flex justify={"center"} alignItems={"center"} h={"100%"}>
<div className="flex flex-col h-full justify-center content-center"> <div className="flex flex-col h-full justify-center content-center">
{transcript.response.status == "processing" ? ( {transcript?.data?.status == "processing" ? (
<Text>Loading Transcript</Text> <Text>Loading Transcript</Text>
) : ( ) : (
<Text> <Text>

View File

@@ -2,7 +2,6 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import Recorder from "../../recorder"; import Recorder from "../../recorder";
import { TopicList } from "../_components/TopicList"; import { TopicList } from "../_components/TopicList";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets"; import { useWebSockets } from "../../useWebSockets";
import { Topic } from "../../webSocketTypes"; import { Topic } from "../../webSocketTypes";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
@@ -11,6 +10,8 @@ import useMp3 from "../../useMp3";
import WaveformLoading from "../../waveformLoading"; import WaveformLoading from "../../waveformLoading";
import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react"; import { Box, Text, Grid, Heading, VStack, Flex } from "@chakra-ui/react";
import LiveTrancription from "../../liveTranscription"; import LiveTrancription from "../../liveTranscription";
import { useTranscriptGet } from "../../../../lib/apiHooks";
import { TranscriptStatus } from "../../../../lib/transcript";
type TranscriptDetails = { type TranscriptDetails = {
params: { params: {
@@ -19,7 +20,7 @@ type TranscriptDetails = {
}; };
const TranscriptRecord = (details: TranscriptDetails) => { const TranscriptRecord = (details: TranscriptDetails) => {
const transcript = useTranscript(details.params.transcriptId); const transcript = useTranscriptGet(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
const useActiveTopic = useState<Topic | null>(null); const useActiveTopic = useState<Topic | null>(null);
@@ -29,8 +30,8 @@ const TranscriptRecord = (details: TranscriptDetails) => {
const router = useRouter(); const router = useRouter();
const [status, setStatus] = useState( const [status, setStatus] = useState<TranscriptStatus>(
webSockets.status.value || transcript.response?.status || "idle", webSockets.status?.value || transcript.data?.status || "idle",
); );
useEffect(() => { useEffect(() => {
@@ -41,7 +42,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
useEffect(() => { useEffect(() => {
//TODO HANDLE ERROR STATUS BETTER //TODO HANDLE ERROR STATUS BETTER
const newStatus = const newStatus =
webSockets.status.value || transcript.response?.status || "idle"; webSockets.status?.value || transcript.data?.status || "idle";
setStatus(newStatus); setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) { if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting"); console.log(newStatus, "redirecting");
@@ -49,7 +50,7 @@ const TranscriptRecord = (details: TranscriptDetails) => {
const newUrl = "/transcripts/" + details.params.transcriptId; const newUrl = "/transcripts/" + details.params.transcriptId;
router.replace(newUrl); router.replace(newUrl);
} }
}, [webSockets.status.value, transcript.response?.status]); }, [webSockets.status?.value, transcript.data?.status]);
useEffect(() => { useEffect(() => {
if (webSockets.waveform && webSockets.waveform) mp3.getNow(); if (webSockets.waveform && webSockets.waveform) mp3.getNow();

View File

@@ -1,12 +1,12 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import useTranscript from "../../useTranscript";
import { useWebSockets } from "../../useWebSockets"; import { useWebSockets } from "../../useWebSockets";
import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock"; import { lockWakeState, releaseWakeState } from "../../../../lib/wakeLock";
import { useRouter } from "next/navigation"; import { useRouter } from "next/navigation";
import useMp3 from "../../useMp3"; import useMp3 from "../../useMp3";
import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react"; import { Center, VStack, Text, Heading, Button } from "@chakra-ui/react";
import FileUploadButton from "../../fileUploadButton"; import FileUploadButton from "../../fileUploadButton";
import { useTranscriptGet } from "../../../../lib/apiHooks";
type TranscriptUpload = { type TranscriptUpload = {
params: { params: {
@@ -15,7 +15,7 @@ type TranscriptUpload = {
}; };
const TranscriptUpload = (details: TranscriptUpload) => { const TranscriptUpload = (details: TranscriptUpload) => {
const transcript = useTranscript(details.params.transcriptId); const transcript = useTranscriptGet(details.params.transcriptId);
const [transcriptStarted, setTranscriptStarted] = useState(false); const [transcriptStarted, setTranscriptStarted] = useState(false);
const webSockets = useWebSockets(details.params.transcriptId); const webSockets = useWebSockets(details.params.transcriptId);
@@ -25,13 +25,13 @@ const TranscriptUpload = (details: TranscriptUpload) => {
const router = useRouter(); const router = useRouter();
const [status_, setStatus] = useState( const [status_, setStatus] = useState(
webSockets.status.value || transcript.response?.status || "idle", webSockets.status?.value || transcript.data?.status || "idle",
); );
// status is obviously done if we have transcript // status is obviously done if we have transcript
const status = const status =
!transcript.loading && transcript.response?.status === "ended" !transcript.isLoading && transcript.data?.status === "ended"
? transcript.response?.status ? transcript.data?.status
: status_; : status_;
useEffect(() => { useEffect(() => {
@@ -43,9 +43,9 @@ const TranscriptUpload = (details: TranscriptUpload) => {
//TODO HANDLE ERROR STATUS BETTER //TODO HANDLE ERROR STATUS BETTER
// TODO deprecate webSockets.status.value / depend on transcript.response?.status from query lib // TODO deprecate webSockets.status.value / depend on transcript.response?.status from query lib
const newStatus = const newStatus =
transcript.response?.status === "ended" transcript.data?.status === "ended"
? "ended" ? "ended"
: webSockets.status.value || transcript.response?.status || "idle"; : webSockets.status?.value || transcript.data?.status || "idle";
setStatus(newStatus); setStatus(newStatus);
if (newStatus && (newStatus == "ended" || newStatus == "error")) { if (newStatus && (newStatus == "ended" || newStatus == "error")) {
console.log(newStatus, "redirecting"); console.log(newStatus, "redirecting");
@@ -53,7 +53,7 @@ const TranscriptUpload = (details: TranscriptUpload) => {
const newUrl = "/transcripts/" + details.params.transcriptId; const newUrl = "/transcripts/" + details.params.transcriptId;
router.replace(newUrl); router.replace(newUrl);
} }
}, [webSockets.status.value, transcript.response?.status]); }, [webSockets.status?.value, transcript.data?.status]);
useEffect(() => { useEffect(() => {
if (webSockets.waveform && webSockets.waveform) mp3.getNow(); if (webSockets.waveform && webSockets.waveform) mp3.getNow();

View File

@@ -9,7 +9,6 @@ import { useRouter } from "next/navigation";
import useCreateTranscript from "../createTranscript"; import useCreateTranscript from "../createTranscript";
import SelectSearch from "react-select-search"; import SelectSearch from "react-select-search";
import { supportedLanguages } from "../../../supportedLanguages"; import { supportedLanguages } from "../../../supportedLanguages";
import { featureEnabled } from "../../../domainContext";
import { import {
Flex, Flex,
Box, Box,
@@ -21,10 +20,9 @@ import {
Spacer, Spacer,
} from "@chakra-ui/react"; } from "@chakra-ui/react";
import { useAuth } from "../../../lib/AuthProvider"; import { useAuth } from "../../../lib/AuthProvider";
import type { components } from "../../../reflector-api"; import { featureEnabled } from "../../../lib/features";
const TranscriptCreate = () => { const TranscriptCreate = () => {
const isClient = typeof window !== "undefined";
const router = useRouter(); const router = useRouter();
const auth = useAuth(); const auth = useAuth();
const isAuthenticated = auth.status === "authenticated"; const isAuthenticated = auth.status === "authenticated";
@@ -176,7 +174,7 @@ const TranscriptCreate = () => {
placeholder="Choose your language" placeholder="Choose your language"
/> />
</Box> </Box>
{isClient && !loading ? ( {!loading ? (
permissionOk ? ( permissionOk ? (
<Spacer /> <Spacer />
) : permissionDenied ? ( ) : permissionDenied ? (

View File

@@ -11,10 +11,11 @@ import useAudioDevice from "./useAudioDevice";
import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react"; import { Box, Flex, IconButton, Menu, RadioGroup } from "@chakra-ui/react";
import { LuScreenShare, LuMic, LuPlay, LuCircleStop } from "react-icons/lu"; import { LuScreenShare, LuMic, LuPlay, LuCircleStop } from "react-icons/lu";
import { RECORD_A_MEETING_URL } from "../../api/urls"; import { RECORD_A_MEETING_URL } from "../../api/urls";
import { TranscriptStatus } from "../../lib/transcript";
type RecorderProps = { type RecorderProps = {
transcriptId: string; transcriptId: string;
status: string; status: TranscriptStatus;
}; };
export default function Recorder(props: RecorderProps) { export default function Recorder(props: RecorderProps) {

View File

@@ -1,5 +1,4 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { featureEnabled } from "../../domainContext";
import { ShareMode, toShareMode } from "../../lib/shareMode"; import { ShareMode, toShareMode } from "../../lib/shareMode";
import type { components } from "../../reflector-api"; import type { components } from "../../reflector-api";
@@ -24,6 +23,8 @@ import ShareCopy from "./shareCopy";
import ShareZulip from "./shareZulip"; import ShareZulip from "./shareZulip";
import { useAuth } from "../../lib/AuthProvider"; import { useAuth } from "../../lib/AuthProvider";
import { featureEnabled } from "../../lib/features";
type ShareAndPrivacyProps = { type ShareAndPrivacyProps = {
finalSummaryRef: any; finalSummaryRef: any;
transcriptResponse: GetTranscript; transcriptResponse: GetTranscript;

View File

@@ -1,8 +1,9 @@
import React, { useState, useRef, useEffect, use } from "react"; import React, { useState, useRef, useEffect, use } from "react";
import { featureEnabled } from "../../domainContext";
import { Button, Flex, Input, Text } from "@chakra-ui/react"; import { Button, Flex, Input, Text } from "@chakra-ui/react";
import QRCode from "react-qr-code"; import QRCode from "react-qr-code";
import { featureEnabled } from "../../lib/features";
type ShareLinkProps = { type ShareLinkProps = {
transcriptId: string; transcriptId: string;
}; };

View File

@@ -1,5 +1,4 @@
import { useState, useEffect, useMemo } from "react"; import { useState, useEffect, useMemo } from "react";
import { featureEnabled } from "../../domainContext";
import type { components } from "../../reflector-api"; import type { components } from "../../reflector-api";
type GetTranscript = components["schemas"]["GetTranscript"]; type GetTranscript = components["schemas"]["GetTranscript"];
@@ -25,6 +24,8 @@ import {
useTranscriptPostToZulip, useTranscriptPostToZulip,
} from "../../lib/apiHooks"; } from "../../lib/apiHooks";
import { featureEnabled } from "../../lib/features";
type ShareZulipProps = { type ShareZulipProps = {
transcriptResponse: GetTranscript; transcriptResponse: GetTranscript;
topicsResponse: GetTranscriptTopic[]; topicsResponse: GetTranscriptTopic[];

View File

@@ -1,7 +1,7 @@
import { useContext, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { DomainContext } from "../../domainContext";
import { useTranscriptGet } from "../../lib/apiHooks"; import { useTranscriptGet } from "../../lib/apiHooks";
import { useAuth } from "../../lib/AuthProvider"; import { useAuth } from "../../lib/AuthProvider";
import { API_URL } from "../../lib/apiClient";
export type Mp3Response = { export type Mp3Response = {
media: HTMLMediaElement | null; media: HTMLMediaElement | null;
@@ -19,7 +19,6 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
null, null,
); );
const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null); const [audioDeleted, setAudioDeleted] = useState<boolean | null>(null);
const { api_url } = useContext(DomainContext);
const auth = useAuth(); const auth = useAuth();
const accessTokenInfo = const accessTokenInfo =
auth.status === "authenticated" ? auth.accessToken : null; auth.status === "authenticated" ? auth.accessToken : null;
@@ -78,7 +77,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
// Audio is not deleted, proceed to load it // Audio is not deleted, proceed to load it
audioElement = document.createElement("audio"); audioElement = document.createElement("audio");
audioElement.src = `${api_url}/v1/transcripts/${transcriptId}/audio/mp3`; audioElement.src = `${API_URL}/v1/transcripts/${transcriptId}/audio/mp3`;
audioElement.crossOrigin = "anonymous"; audioElement.crossOrigin = "anonymous";
audioElement.preload = "auto"; audioElement.preload = "auto";
@@ -110,7 +109,7 @@ const useMp3 = (transcriptId: string, waiting?: boolean): Mp3Response => {
if (handleError) audioElement.removeEventListener("error", handleError); if (handleError) audioElement.removeEventListener("error", handleError);
} }
}; };
}, [transcriptId, transcript, later, api_url]); }, [transcriptId, transcript, later]);
const getNow = () => { const getNow = () => {
setLater(false); setLater(false);

View File

@@ -1,69 +0,0 @@
import type { components } from "../../reflector-api";
import { useTranscriptGet } from "../../lib/apiHooks";
type GetTranscript = components["schemas"]["GetTranscript"];
type ErrorTranscript = {
error: Error;
loading: false;
response: null;
reload: () => void;
};
type LoadingTranscript = {
response: null;
loading: true;
error: false;
reload: () => void;
};
type SuccessTranscript = {
response: GetTranscript;
loading: false;
error: null;
reload: () => void;
};
const useTranscript = (
id: string | null,
): ErrorTranscript | LoadingTranscript | SuccessTranscript => {
const { data, isLoading, error, refetch } = useTranscriptGet(id);
// Map to the expected return format
if (isLoading) {
return {
response: null,
loading: true,
error: false,
reload: refetch,
};
}
if (error) {
return {
error: error as Error,
loading: false,
response: null,
reload: refetch,
};
}
// Check if data is undefined or null
if (!data) {
return {
response: null,
loading: true,
error: false,
reload: refetch,
};
}
return {
response: data,
loading: false,
error: null,
reload: refetch,
};
};
export default useTranscript;

View File

@@ -1,13 +1,12 @@
import { useContext, useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { Topic, FinalSummary, Status } from "./webSocketTypes"; import { Topic, FinalSummary, Status } from "./webSocketTypes";
import { useError } from "../../(errors)/errorContext"; import { useError } from "../../(errors)/errorContext";
import { DomainContext } from "../../domainContext";
import type { components } from "../../reflector-api"; import type { components } from "../../reflector-api";
type AudioWaveform = components["schemas"]["AudioWaveform"]; type AudioWaveform = components["schemas"]["AudioWaveform"];
type GetTranscriptSegmentTopic = type GetTranscriptSegmentTopic =
components["schemas"]["GetTranscriptSegmentTopic"]; components["schemas"]["GetTranscriptSegmentTopic"];
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { $api } from "../../lib/apiClient"; import { $api, WEBSOCKET_URL } from "../../lib/apiClient";
export type UseWebSockets = { export type UseWebSockets = {
transcriptTextLive: string; transcriptTextLive: string;
@@ -16,7 +15,7 @@ export type UseWebSockets = {
title: string; title: string;
topics: Topic[]; topics: Topic[];
finalSummary: FinalSummary; finalSummary: FinalSummary;
status: Status; status: Status | null;
waveform: AudioWaveform | null; waveform: AudioWaveform | null;
duration: number | null; duration: number | null;
}; };
@@ -34,10 +33,9 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
const [finalSummary, setFinalSummary] = useState<FinalSummary>({ const [finalSummary, setFinalSummary] = useState<FinalSummary>({
summary: "", summary: "",
}); });
const [status, setStatus] = useState<Status>({ value: "" }); const [status, setStatus] = useState<Status | null>(null);
const { setError } = useError(); const { setError } = useError();
const { websocket_url: websocketUrl } = useContext(DomainContext);
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [accumulatedText, setAccumulatedText] = useState<string>(""); const [accumulatedText, setAccumulatedText] = useState<string>("");
@@ -328,7 +326,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
if (!transcriptId) return; if (!transcriptId) return;
const url = `${websocketUrl}/v1/transcripts/${transcriptId}/events`; const url = `${WEBSOCKET_URL}/v1/transcripts/${transcriptId}/events`;
let ws = new WebSocket(url); let ws = new WebSocket(url);
ws.onopen = () => { ws.onopen = () => {
@@ -494,7 +492,7 @@ export const useWebSockets = (transcriptId: string | null): UseWebSockets => {
return () => { return () => {
ws.close(); ws.close();
}; };
}, [transcriptId, websocketUrl]); }, [transcriptId]);
return { return {
transcriptTextLive, transcriptTextLive,

View File

@@ -1,4 +1,5 @@
import type { components } from "../../reflector-api"; import type { components } from "../../reflector-api";
import type { TranscriptStatus } from "../../lib/transcript";
type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"]; type GetTranscriptTopic = components["schemas"]["GetTranscriptTopic"];
@@ -13,7 +14,7 @@ export type FinalSummary = {
}; };
export type Status = { export type Status = {
value: string; value: TranscriptStatus;
}; };
export type TranslatedTopic = { export type TranslatedTopic = {

View File

@@ -1,49 +0,0 @@
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { DomainConfig } from "./lib/edgeConfig";
type DomainContextType = Omit<DomainConfig, "auth_callback_url">;
export const DomainContext = createContext<DomainContextType>({
features: {
requireLogin: false,
privacy: true,
browse: false,
sendToZulip: false,
},
api_url: "",
websocket_url: "",
});
export const DomainContextProvider = ({
config,
children,
}: {
config: DomainConfig;
children: any;
}) => {
const [context, setContext] = useState<DomainContextType>();
useEffect(() => {
if (!config) return;
const { auth_callback_url, ...others } = config;
setContext(others);
}, [config]);
if (!context) return;
return (
<DomainContext.Provider value={context}>{children}</DomainContext.Provider>
);
};
// Get feature config client-side with
export const featureEnabled = (
featureName: "requireLogin" | "privacy" | "browse" | "sendToZulip",
) => {
const context = useContext(DomainContext);
return context.features[featureName] as boolean | undefined;
};
// Get config server-side (out of react) : see lib/edgeConfig.

View File

@@ -3,9 +3,7 @@ import { Metadata, Viewport } from "next";
import { Poppins } from "next/font/google"; import { Poppins } from "next/font/google";
import { ErrorProvider } from "./(errors)/errorContext"; import { ErrorProvider } from "./(errors)/errorContext";
import ErrorMessage from "./(errors)/errorMessage"; import ErrorMessage from "./(errors)/errorMessage";
import { DomainContextProvider } from "./domainContext";
import { RecordingConsentProvider } from "./recordingConsentContext"; import { RecordingConsentProvider } from "./recordingConsentContext";
import { getConfig } from "./lib/edgeConfig";
import { ErrorBoundary } from "@sentry/nextjs"; import { ErrorBoundary } from "@sentry/nextjs";
import { Providers } from "./providers"; import { Providers } from "./providers";
@@ -68,21 +66,17 @@ export default async function RootLayout({
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const config = await getConfig();
return ( return (
<html lang="en" className={poppins.className} suppressHydrationWarning> <html lang="en" className={poppins.className} suppressHydrationWarning>
<body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}> <body className={"h-[100svh] w-[100svw] overflow-x-hidden relative"}>
<DomainContextProvider config={config}> <RecordingConsentProvider>
<RecordingConsentProvider> <ErrorBoundary fallback={<p>"something went really wrong"</p>}>
<ErrorBoundary fallback={<p>"something went really wrong"</p>}> <ErrorProvider>
<ErrorProvider> <ErrorMessage />
<ErrorMessage /> <Providers>{children}</Providers>
<Providers>{children}</Providers> </ErrorProvider>
</ErrorProvider> </ErrorBoundary>
</ErrorBoundary> </RecordingConsentProvider>
</RecordingConsentProvider>
</DomainContextProvider>
</body> </body>
</html> </html>
); );

View File

@@ -88,8 +88,13 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
}; };
// not useEffect, we need it ASAP // not useEffect, we need it ASAP
// apparently, still no guarantee this code runs before mutations are fired
configureApiAuth( configureApiAuth(
contextValue.status === "authenticated" ? contextValue.accessToken : null, contextValue.status === "authenticated"
? contextValue.accessToken
: contextValue.status === "loading"
? undefined
: null,
); );
return ( return (

View File

@@ -9,9 +9,7 @@
import { useEffect } from "react"; import { useEffect } from "react";
import { useAuth } from "./AuthProvider"; import { useAuth } from "./AuthProvider";
import { REFRESH_ACCESS_TOKEN_BEFORE } from "./auth"; import { shouldRefreshToken } from "./auth";
const REFRESH_BEFORE = REFRESH_ACCESS_TOKEN_BEFORE;
export function SessionAutoRefresh({ children }) { export function SessionAutoRefresh({ children }) {
const auth = useAuth(); const auth = useAuth();
@@ -25,8 +23,7 @@ export function SessionAutoRefresh({ children }) {
const INTERVAL_REFRESH_MS = 5000; const INTERVAL_REFRESH_MS = 5000;
const interval = setInterval(() => { const interval = setInterval(() => {
if (accessTokenExpires === null) return; if (accessTokenExpires === null) return;
const timeLeft = accessTokenExpires - Date.now(); if (shouldRefreshToken(accessTokenExpires)) {
if (timeLeft < REFRESH_BEFORE) {
auth auth
.update() .update()
.then(() => {}) .then(() => {})

View File

@@ -2,32 +2,43 @@
import createClient from "openapi-fetch"; import createClient from "openapi-fetch";
import type { paths } from "../reflector-api"; import type { paths } from "../reflector-api";
import {
queryOptions,
useMutation,
useQuery,
useSuspenseQuery,
} from "@tanstack/react-query";
import createFetchClient from "openapi-react-query"; import createFetchClient from "openapi-react-query";
import { assertExistsAndNonEmptyString } from "./utils"; import { assertExistsAndNonEmptyString } from "./utils";
import { isBuildPhase } from "./next"; import { isBuildPhase } from "./next";
const API_URL = !isBuildPhase export const API_URL = !isBuildPhase
? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL) ? assertExistsAndNonEmptyString(process.env.NEXT_PUBLIC_API_URL)
: "http://localhost"; : "http://localhost";
// Create the base openapi-fetch client with a default URL // TODO decide strict validation or not
// The actual URL will be set via middleware in AuthProvider export const WEBSOCKET_URL =
process.env.NEXT_PUBLIC_WEBSOCKET_URL || "ws://127.0.0.1:1250";
export const client = createClient<paths>({ export const client = createClient<paths>({
baseUrl: API_URL, baseUrl: API_URL,
}); });
export const $api = createFetchClient<paths>(client); const waitForAuthTokenDefinitivePresenceOrAbscence = async () => {
let tries = 0;
let currentAuthToken: string | null | undefined = null; let time = 0;
const STEP = 100;
while (currentAuthToken === undefined) {
await new Promise((resolve) => setTimeout(resolve, STEP));
time += STEP;
tries++;
// most likely first try is more than enough, if it's more there's already something weird happens
if (tries > 10) {
// even when there's no auth assumed at all, we probably should explicitly call configureApiAuth(null)
throw new Error(
`Could not get auth token definitive presence/absence in ${time}ms. not calling configureApiAuth?`,
);
}
}
};
client.use({ client.use({
onRequest({ request }) { async onRequest({ request }) {
await waitForAuthTokenDefinitivePresenceOrAbscence();
if (currentAuthToken) { if (currentAuthToken) {
request.headers.set("Authorization", `Bearer ${currentAuthToken}`); request.headers.set("Authorization", `Bearer ${currentAuthToken}`);
} }
@@ -44,7 +55,13 @@ client.use({
}, },
}); });
export const $api = createFetchClient<paths>(client);
let currentAuthToken: string | null | undefined = undefined;
// the function contract: lightweight, idempotent // the function contract: lightweight, idempotent
export const configureApiAuth = (token: string | null | undefined) => { export const configureApiAuth = (token: string | null | undefined) => {
// watch only for the initial loading; "reloading" state assumes token presence/absence
if (token === undefined && currentAuthToken !== undefined) return;
currentAuthToken = token; currentAuthToken = token;
}; };

View File

@@ -96,8 +96,6 @@ export function useTranscriptProcess() {
} }
export function useTranscriptGet(transcriptId: string | null) { export function useTranscriptGet(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery( return $api.useQuery(
"get", "get",
"/v1/transcripts/{transcript_id}", "/v1/transcripts/{transcript_id}",
@@ -109,7 +107,7 @@ export function useTranscriptGet(transcriptId: string | null) {
}, },
}, },
{ {
enabled: !!transcriptId && isAuthenticated, enabled: !!transcriptId,
}, },
); );
} }
@@ -292,18 +290,16 @@ export function useTranscriptUploadAudio() {
} }
export function useTranscriptWaveform(transcriptId: string | null) { export function useTranscriptWaveform(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery( return $api.useQuery(
"get", "get",
"/v1/transcripts/{transcript_id}/audio/waveform", "/v1/transcripts/{transcript_id}/audio/waveform",
{ {
params: { params: {
path: { transcript_id: transcriptId || "" }, path: { transcript_id: transcriptId! },
}, },
}, },
{ {
enabled: !!transcriptId && isAuthenticated, enabled: !!transcriptId,
}, },
); );
} }
@@ -316,7 +312,7 @@ export function useTranscriptMP3(transcriptId: string | null) {
"/v1/transcripts/{transcript_id}/audio/mp3", "/v1/transcripts/{transcript_id}/audio/mp3",
{ {
params: { params: {
path: { transcript_id: transcriptId || "" }, path: { transcript_id: transcriptId! },
}, },
}, },
{ {
@@ -326,8 +322,6 @@ export function useTranscriptMP3(transcriptId: string | null) {
} }
export function useTranscriptTopics(transcriptId: string | null) { export function useTranscriptTopics(transcriptId: string | null) {
const { isAuthenticated } = useAuthReady();
return $api.useQuery( return $api.useQuery(
"get", "get",
"/v1/transcripts/{transcript_id}/topics", "/v1/transcripts/{transcript_id}/topics",
@@ -337,7 +331,7 @@ export function useTranscriptTopics(transcriptId: string | null) {
}, },
}, },
{ {
enabled: !!transcriptId && isAuthenticated, enabled: !!transcriptId,
}, },
); );
} }

View File

@@ -1,7 +1,14 @@
import { assertExistsAndNonEmptyString } from "./utils";
export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const; export const REFRESH_ACCESS_TOKEN_ERROR = "RefreshAccessTokenError" as const;
// 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min // 4 min is 1 min less than default authentic value. here we assume that authentic won't be set to access tokens < 4 min
export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000; export const REFRESH_ACCESS_TOKEN_BEFORE = 4 * 60 * 1000;
export const shouldRefreshToken = (accessTokenExpires: number): boolean => {
const timeLeft = accessTokenExpires - Date.now();
return timeLeft < REFRESH_ACCESS_TOKEN_BEFORE;
};
export const LOGIN_REQUIRED_PAGES = [ export const LOGIN_REQUIRED_PAGES = [
"/transcripts/[!new]", "/transcripts/[!new]",
"/browse(.*)", "/browse(.*)",

View File

@@ -10,6 +10,7 @@ import {
import { import {
REFRESH_ACCESS_TOKEN_BEFORE, REFRESH_ACCESS_TOKEN_BEFORE,
REFRESH_ACCESS_TOKEN_ERROR, REFRESH_ACCESS_TOKEN_ERROR,
shouldRefreshToken,
} from "./auth"; } from "./auth";
import { import {
getTokenCache, getTokenCache,
@@ -85,9 +86,13 @@ export const authOptions: AuthOptions = {
"currentToken from cache", "currentToken from cache",
JSON.stringify(currentToken, null, 2), JSON.stringify(currentToken, null, 2),
"will be returned?", "will be returned?",
currentToken && Date.now() < currentToken.token.accessTokenExpires, currentToken &&
!shouldRefreshToken(currentToken.token.accessTokenExpires),
); );
if (currentToken && Date.now() < currentToken.token.accessTokenExpires) { if (
currentToken &&
!shouldRefreshToken(currentToken.token.accessTokenExpires)
) {
return currentToken.token; return currentToken.token;
} }
@@ -128,7 +133,7 @@ async function lockedRefreshAccessToken(
if (cached) { if (cached) {
if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) { if (Date.now() - cached.timestamp > TOKEN_CACHE_TTL) {
await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`); await deleteTokenCache(tokenCacheRedis, `token:${token.sub}`);
} else if (Date.now() < cached.token.accessTokenExpires) { } else if (!shouldRefreshToken(cached.token.accessTokenExpires)) {
console.debug("returning cached token", cached.token); console.debug("returning cached token", cached.token);
return cached.token; return cached.token;
} }

View File

@@ -1,54 +0,0 @@
import { get } from "@vercel/edge-config";
import { isBuildPhase } from "./next";
type EdgeConfig = {
[domainWithDash: string]: {
features: {
[featureName in
| "requireLogin"
| "privacy"
| "browse"
| "sendToZulip"]: boolean;
};
auth_callback_url: string;
websocket_url: string;
api_url: string;
};
};
export type DomainConfig = EdgeConfig["domainWithDash"];
// Edge config main keys can only be alphanumeric and _ or -
export function edgeKeyToDomain(key: string) {
return key.replaceAll("_", ".");
}
export function edgeDomainToKey(domain: string) {
return domain.replaceAll(".", "_");
}
// get edge config server-side (prefer DomainContext when available), domain is the hostname
export async function getConfig() {
if (process.env.NEXT_PUBLIC_ENV === "development") {
try {
return require("../../config").localConfig;
} catch (e) {
// next build() WILL try to execute the require above even if conditionally protected
// but thank god it at least runs catch{} block properly
if (!isBuildPhase) throw new Error(e);
return require("../../config-template").localConfig;
}
}
const domain = new URL(process.env.NEXT_PUBLIC_SITE_URL!).hostname;
let config = await get(edgeDomainToKey(domain));
if (typeof config !== "object") {
console.warn("No config for this domain, falling back to default");
config = await get(edgeDomainToKey("default"));
}
if (typeof config !== "object") throw Error("Error fetching config");
return config as DomainConfig;
}

55
www/app/lib/features.ts Normal file
View File

@@ -0,0 +1,55 @@
export const FEATURES = [
"requireLogin",
"privacy",
"browse",
"sendToZulip",
"rooms",
] as const;
export type FeatureName = (typeof FEATURES)[number];
export type Features = Readonly<Record<FeatureName, boolean>>;
export const DEFAULT_FEATURES: Features = {
requireLogin: false,
privacy: true,
browse: false,
sendToZulip: false,
rooms: false,
} as const;
function parseBooleanEnv(
value: string | undefined,
defaultValue: boolean = false,
): boolean {
if (!value) return defaultValue;
return value.toLowerCase() === "true";
}
// WARNING: keep process.env.* as-is, next.js won't see them if you generate dynamically
const features: Features = {
requireLogin: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_REQUIRE_LOGIN,
DEFAULT_FEATURES.requireLogin,
),
privacy: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_PRIVACY,
DEFAULT_FEATURES.privacy,
),
browse: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_BROWSE,
DEFAULT_FEATURES.browse,
),
sendToZulip: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_SEND_TO_ZULIP,
DEFAULT_FEATURES.sendToZulip,
),
rooms: parseBooleanEnv(
process.env.NEXT_PUBLIC_FEATURE_ROOMS,
DEFAULT_FEATURES.rooms,
),
};
export const featureEnabled = (featureName: FeatureName): boolean => {
return features[featureName];
};

View File

@@ -0,0 +1,5 @@
import { components } from "../reflector-api";
type ApiTranscriptStatus = components["schemas"]["GetTranscript"]["status"];
export type TranscriptStatus = ApiTranscriptStatus;

View File

@@ -72,3 +72,7 @@ export const assertCustomSession = <S extends Session>(s: S): CustomSession => {
// no other checks for now // no other checks for now
return r as CustomSession; return r as CustomSession;
}; };
export type Mutable<T> = {
-readonly [P in keyof T]: T[P];
};

View File

@@ -171,5 +171,6 @@ export const assertNotExists = <T>(
export const assertExistsAndNonEmptyString = ( export const assertExistsAndNonEmptyString = (
value: string | null | undefined, value: string | null | undefined,
err?: string,
): NonEmptyString => ): NonEmptyString =>
parseNonEmptyString(assertExists(value, "Expected non-empty string")); parseNonEmptyString(assertExists(value, err || "Expected non-empty string"));

View File

@@ -2,8 +2,8 @@
import { ChakraProvider } from "@chakra-ui/react"; import { ChakraProvider } from "@chakra-ui/react";
import system from "./styles/theme"; import system from "./styles/theme";
import dynamic from "next/dynamic";
import { WherebyProvider } from "@whereby.com/browser-sdk/react";
import { Toaster } from "./components/ui/toaster"; import { Toaster } from "./components/ui/toaster";
import { NuqsAdapter } from "nuqs/adapters/next/app"; import { NuqsAdapter } from "nuqs/adapters/next/app";
import { QueryClientProvider } from "@tanstack/react-query"; import { QueryClientProvider } from "@tanstack/react-query";
@@ -11,6 +11,14 @@ import { queryClient } from "./lib/queryClient";
import { AuthProvider } from "./lib/AuthProvider"; import { AuthProvider } from "./lib/AuthProvider";
import { SessionProvider as SessionProviderNextAuth } from "next-auth/react"; import { SessionProvider as SessionProviderNextAuth } from "next-auth/react";
const WherebyProvider = dynamic(
() =>
import("@whereby.com/browser-sdk/react").then((mod) => ({
default: mod.WherebyProvider,
})),
{ ssr: false },
);
export function Providers({ children }: { children: React.ReactNode }) { export function Providers({ children }: { children: React.ReactNode }) {
return ( return (
<NuqsAdapter> <NuqsAdapter>

View File

@@ -926,8 +926,17 @@ export interface components {
source_kind: components["schemas"]["SourceKind"]; source_kind: components["schemas"]["SourceKind"];
/** Created At */ /** Created At */
created_at: string; created_at: string;
/** Status */ /**
status: string; * Status
* @enum {string}
*/
status:
| "idle"
| "uploaded"
| "recording"
| "processing"
| "error"
| "ended";
/** Rank */ /** Rank */
rank: number; rank: number;
/** /**

View File

@@ -1,13 +0,0 @@
export const localConfig = {
features: {
requireLogin: true,
privacy: true,
browse: true,
sendToZulip: true,
rooms: true,
},
api_url: "http://127.0.0.1:1250",
websocket_url: "ws://127.0.0.1:1250",
auth_callback_url: "http://localhost:3000/auth-callback",
zulip_streams: "", // Find the value on zulip
};

View File

@@ -1,5 +1,5 @@
import { withAuth } from "next-auth/middleware"; import { withAuth } from "next-auth/middleware";
import { getConfig } from "./app/lib/edgeConfig"; import { featureEnabled } from "./app/lib/features";
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
import { PROTECTED_PAGES } from "./app/lib/auth"; import { PROTECTED_PAGES } from "./app/lib/auth";
@@ -19,13 +19,12 @@ export const config = {
export default withAuth( export default withAuth(
async function middleware(request) { async function middleware(request) {
const config = await getConfig();
const pathname = request.nextUrl.pathname; const pathname = request.nextUrl.pathname;
// feature-flags protected paths // feature-flags protected paths
if ( if (
(!config.features.browse && pathname.startsWith("/browse")) || (!featureEnabled("browse") && pathname.startsWith("/browse")) ||
(!config.features.rooms && pathname.startsWith("/rooms")) (!featureEnabled("rooms") && pathname.startsWith("/rooms"))
) { ) {
return NextResponse.redirect(request.nextUrl.origin); return NextResponse.redirect(request.nextUrl.origin);
} }
@@ -33,10 +32,8 @@ export default withAuth(
{ {
callbacks: { callbacks: {
async authorized({ req, token }) { async authorized({ req, token }) {
const config = await getConfig();
if ( if (
config.features.requireLogin && featureEnabled("requireLogin") &&
PROTECTED_PAGES.test(req.nextUrl.pathname) PROTECTED_PAGES.test(req.nextUrl.pathname)
) { ) {
return !!token; return !!token;

View File

@@ -20,7 +20,6 @@
"@sentry/nextjs": "^7.77.0", "@sentry/nextjs": "^7.77.0",
"@tanstack/react-query": "^5.85.9", "@tanstack/react-query": "^5.85.9",
"@types/ioredis": "^5.0.0", "@types/ioredis": "^5.0.0",
"@vercel/edge-config": "^0.4.1",
"@whereby.com/browser-sdk": "^3.3.4", "@whereby.com/browser-sdk": "^3.3.4",
"autoprefixer": "10.4.20", "autoprefixer": "10.4.20",
"axios": "^1.8.2", "axios": "^1.8.2",
@@ -63,8 +62,7 @@
"jest": "^30.1.3", "jest": "^30.1.3",
"openapi-typescript": "^7.9.1", "openapi-typescript": "^7.9.1",
"prettier": "^3.0.0", "prettier": "^3.0.0",
"ts-jest": "^29.4.1", "ts-jest": "^29.4.1"
"vercel": "^37.3.0"
}, },
"packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748" "packageManager": "pnpm@10.14.0+sha512.ad27a79641b49c3e481a16a805baa71817a04bbe06a38d17e60e2eaee83f6a146c6a688125f5792e48dd5ba30e7da52a5cda4c3992b9ccf333f9ce223af84748"
} }

2562
www/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff