Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
57ebf4481b | ||
|
|
f50f50be72 |
+1
-1
@@ -376,7 +376,7 @@ Fork = `LotusGuild/element-call` (branch `lotus`, upstream base **v0.25.0** sinc
|
||||
|
||||
**Toolchain (upstream-driven, accepted 2026-09):** Node ≥ 22.13 (`.node-version` = 24) and **pnpm 11**, installed directly (`npm i -g pnpm@<packageManager version>`, currently 11.21.0) — **not** via `corepack enable`: `matrix-js-sdk` is a git dependency pnpm builds from source, and its own devEngines pins pnpm 11.9.0; a corepack-shimmed pnpm refuses to switch for that nested install and `pnpm install` fails (fork CI run #1854). pnpm 10 rejects the lockfile and Node 20 cannot build. Lint is **oxlint + oxfmt** (upstream dropped eslint/prettier in v0.25.0): `pnpm lint` (tsc + oxlint + knip) and `pnpm format:check` / `pnpm format`. `matrix-js-sdk` is pinned to a `matrix-org/matrix-js-sdk#develop` commit in the lockfile, as upstream ships it. Fork CI (`.gitea/workflows/ci.yml`) hard-gates lint + format + `pnpm test:unit` before build, with `concurrency: cancel-in-progress`.
|
||||
|
||||
**Publish a new version (CI on tag push; needs the `GITEA_NPM_TOKEN` secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.1`, published by CI 2026-09-12 — the first CI publish; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.1`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
|
||||
**Publish a new version (CI on tag push; needs the `GITEA_NPM_TOKEN` secret):** the published version is derived from the git tag — bump `embedded/web/package.json` (currently `0.25.0-lotus.2`, published by CI; the secret is `NPM_PUBLISH_TOKEN`, names starting `GITEA_` are reserved), push `lotus`, then `git push lotus v0.25.0-lotus.1`; the `publish` job builds and publishes to the Gitea registry. Always push (never delete) the annotated `vX.Y.Z-lotus.N` tag for every published version. Then in cinny bump the `@lotusguild/element-call-embedded` pin (currently `0.25.0-lotus.2`) → `npm install` → build. Manual fallback: `pnpm run build:embedded && cd embedded/web && npm version <ver> --no-git-tag-version && npm publish`.
|
||||
|
||||
**`io.lotus.*` widget actions** (add new toWidget actions to the enum + `LOTUS_TO_WIDGET_ACTIONS` in `src/lotus/lotusActions.ts`; only send AFTER call-join or a 10s timeout fires):
|
||||
|
||||
|
||||
@@ -196,7 +196,7 @@ The dev server defaults to **port 8080** (`vite.config.js`); if 8080 is already
|
||||
### 🔱 Element Call fork ("Lotus Call") — LIVE
|
||||
|
||||
Voice/video channels embed **Element Call**, which is now our **self-built fork**
|
||||
(`@lotusguild/element-call-embedded` `0.25.0-lotus.1`, upstream base v0.25.0, source at
|
||||
(`@lotusguild/element-call-embedded` `0.25.0-lotus.2`, upstream base v0.25.0, source at
|
||||
`LotusGuild/element-call`), published to our private Gitea npm registry and served
|
||||
same-origin. We no longer depend on the upstream prebuilt bundle, so in-call
|
||||
behavior is editable source instead of fragile DOM/widget hacks.
|
||||
|
||||
Generated
+4
-4
@@ -80,7 +80,7 @@
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.1",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.2",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
@@ -2681,9 +2681,9 @@
|
||||
"integrity": "sha512-dfLbk+PwWvFzSxwk3n5ySL0hfBog779o8h68wK/7/APo/7cgyWp5jcXockbxdk5kFRkbeXWm4Fbi9FrdN381sA=="
|
||||
},
|
||||
"node_modules/@lotusguild/element-call-embedded": {
|
||||
"version": "0.25.0-lotus.1",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.1/element-call-embedded-0.25.0-lotus.1.tgz",
|
||||
"integrity": "sha512-tiVC7SD3cS1MMc9GZgzl6H7jvllHhOMvTivYqQT7pRaU4vP2M7IQNqL8KNCyXnXKaZAMIn7OnbI4thnQAC9p8Q==",
|
||||
"version": "0.25.0-lotus.2",
|
||||
"resolved": "https://code.lotusguild.org/api/packages/LotusGuild/npm/%40lotusguild%2Felement-call-embedded/-/0.25.0-lotus.2/element-call-embedded-0.25.0-lotus.2.tgz",
|
||||
"integrity": "sha512-Ck1CJBezQg7mjoSuBmSkIHn4PO+m0m8tsIdqCwjNWQDTuq4zIDuDAtOFvikyv9DTn3ScBh9IrspUf0ZEYdS2HA==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@matrix-org/matrix-sdk-crypto-wasm": {
|
||||
|
||||
+1
-1
@@ -107,7 +107,7 @@
|
||||
"workbox-precaching": "7.4.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.1",
|
||||
"@lotusguild/element-call-embedded": "0.25.0-lotus.2",
|
||||
"@playwright/test": "1.63.0",
|
||||
"@rollup/plugin-inject": "5.0.5",
|
||||
"@rollup/plugin-wasm": "6.2.2",
|
||||
|
||||
@@ -71,7 +71,12 @@ function ParticipantMenu({
|
||||
if (isFocused) {
|
||||
callEmbed?.control.clearFocusParticipant();
|
||||
} else {
|
||||
callEmbed?.control.focusCameraParticipant(userId);
|
||||
// [EC#30] Pass the fork's per-device media id when we have one (from
|
||||
// io.lotus.call_state) so a multi-device user pins the active device.
|
||||
const parts = callEmbed?.getLotusParticipants() ?? [];
|
||||
const mine = parts.filter((p) => p.userId === userId);
|
||||
const pick = mine.find((p) => p.speaking) ?? mine.find((p) => p.audioEnabled) ?? mine[0];
|
||||
callEmbed?.control.focusCameraParticipant(userId, pick?.id ?? null);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -115,7 +115,18 @@ export function CallSoundboard({ callEmbed }: CallSoundboardProps) {
|
||||
try {
|
||||
const url = await resolveClipObjectUrl(mx, flat.clip.url);
|
||||
const vol = (flat.clip.volume / 100) * master;
|
||||
callEmbed.control.injectAudio(url, vol);
|
||||
const result = await callEmbed.control.injectAudio(url, vol);
|
||||
if (!result.played) {
|
||||
// [EC#13] Refused fork-side (only reason today: local mic muted) —
|
||||
// don't play it locally either, or the user would think it went out.
|
||||
setError(
|
||||
result.reason === 'muted'
|
||||
? 'Unmute your microphone to play a soundboard clip.'
|
||||
: 'Could not play that clip.',
|
||||
);
|
||||
done();
|
||||
return;
|
||||
}
|
||||
const audio = playClipLocally(url, vol);
|
||||
if (audio) {
|
||||
audio.addEventListener('ended', done, { once: true });
|
||||
|
||||
@@ -51,6 +51,11 @@ export function LotusDecorationPusher({ callEmbed }: { callEmbed: CallEmbed }):
|
||||
pushTimer.current = setTimeout(push, 300);
|
||||
}, [push]);
|
||||
|
||||
// [Gitea #17] The fork asks for a re-push when its decoration handler
|
||||
// (re)registers; our map hasn't changed, so the change-driven push above
|
||||
// would never fire on its own.
|
||||
useEffect(() => callEmbed.onForkStateRequest(schedulePush), [callEmbed, schedulePush]);
|
||||
|
||||
const onResolve = useCallback(
|
||||
(userId: string, url: string | null) => {
|
||||
const prev = map.current.get(userId);
|
||||
|
||||
@@ -38,6 +38,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
// instead of a one-way pin with no way back. null == no manual pin (speaker-follows).
|
||||
private _focusedUserId: string | null = null;
|
||||
|
||||
private _focusedMediaId: string | null = null;
|
||||
|
||||
// C-M3: last quality payload requested via setQuality(). Held so we can (re)send
|
||||
// it once joined (io.lotus.set_quality must not be sent before call-join — a
|
||||
// pre-join send pends to a 10s widget timeout, mirroring the deafen gate).
|
||||
@@ -183,6 +185,8 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
public resendForkState(): void {
|
||||
this.sendDeafenState();
|
||||
this.sendQuality();
|
||||
// [Gitea #17] The pin lives fork-side and is dropped on a handler remount.
|
||||
if (this._focusedUserId !== null) this.sendFocus(this._focusedUserId, this._focusedMediaId);
|
||||
}
|
||||
|
||||
public startObserving() {
|
||||
@@ -449,13 +453,23 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
return this._focusedUserId;
|
||||
}
|
||||
|
||||
public focusCameraParticipant(userId: string): void {
|
||||
private sendFocus(userId: string, id: string | null): void {
|
||||
// [EC#30] `id` is the fork's per-device media id (userId:deviceId) taken
|
||||
// from io.lotus.call_state, so a multi-device user pins the right device;
|
||||
// the fork falls back to userId (preferring the speaking device) when absent.
|
||||
this.call.transport
|
||||
.send('io.lotus.focus_participant', id ? { userId, id } : { userId })
|
||||
.catch(() => undefined);
|
||||
}
|
||||
|
||||
public focusCameraParticipant(userId: string, id: string | null = null): void {
|
||||
// [lotus #4] Pin the participant via the fork's widget action instead of
|
||||
// DOM-poking tiles. EC's layout honors it — including surfacing the camera
|
||||
// alongside a screenshare (A5) — and it's version-stable. The fork always
|
||||
// acks, so the promise resolves regardless.
|
||||
this._focusedUserId = userId;
|
||||
this.call.transport.send('io.lotus.focus_participant', { userId }).catch(() => undefined);
|
||||
this._focusedMediaId = id;
|
||||
this.sendFocus(userId, id);
|
||||
// [Gitea #56] Notify state-update listeners so the menu can flip to "Unfocus camera".
|
||||
this.emitStateUpdate();
|
||||
}
|
||||
@@ -466,6 +480,7 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
// dispose() calls this unconditionally on every call teardown.
|
||||
if (this._focusedUserId === null) return;
|
||||
this._focusedUserId = null;
|
||||
this._focusedMediaId = null;
|
||||
this.call.transport.send('io.lotus.focus_participant', { userId: null }).catch(() => undefined);
|
||||
this.emitStateUpdate();
|
||||
}
|
||||
@@ -481,8 +496,16 @@ export class CallControl extends EventEmitter implements CallControlState {
|
||||
* The local user does not hear their own published track, so callers should
|
||||
* also play the clip locally for feedback.
|
||||
*/
|
||||
public injectAudio(url: string, volume = 1): void {
|
||||
this.call.transport.send('io.lotus.inject_audio', { url, volume }).catch(() => undefined);
|
||||
public injectAudio(url: string, volume = 1): Promise<{ played: boolean; reason?: string }> {
|
||||
// [EC#13] The fork now refuses while the local mic is muted and replies
|
||||
// { played:false, reason:"muted" }; older forks reply {} (treated as played).
|
||||
return this.call.transport
|
||||
.send<{ url: string; volume: number }, { played?: boolean; reason?: string }>(
|
||||
'io.lotus.inject_audio',
|
||||
{ url, volume },
|
||||
)
|
||||
.then((r) => ({ played: r?.played !== false, reason: r?.reason }))
|
||||
.catch(() => ({ played: true }));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -68,6 +68,9 @@ export class CallEmbed {
|
||||
|
||||
private lotusCallStateListeners = new Set<() => void>();
|
||||
|
||||
// [Gitea #17] Listeners notified when the fork sends io.lotus.request_state.
|
||||
private forkStateRequestListeners = new Set<() => void>();
|
||||
|
||||
public readonly control: CallControl;
|
||||
|
||||
private readonly container: HTMLElement;
|
||||
@@ -373,6 +376,17 @@ export class CallEmbed {
|
||||
// [lotus #2] Consume the fork's per-participant call-state stream. listenAction
|
||||
// auto-replies {} so the fork's transport doesn't time out. Stored for the
|
||||
// speaker/mute hooks (which prefer this over DOM scraping).
|
||||
// [Gitea #17 / EC#17] The fork asks for a full state re-push whenever its
|
||||
// lotus handlers (re)register — e.g. an EC-side InCallView remount that
|
||||
// does not unmount us. Re-send the sticky fork state (deafen/quality/focus)
|
||||
// and let the decoration pusher re-send its roster.
|
||||
this.disposables.push(
|
||||
this.listenAction('io.lotus.request_state', () => {
|
||||
if (!this.joined) return;
|
||||
this.control.resendForkState();
|
||||
this.forkStateRequestListeners.forEach((l) => l());
|
||||
}),
|
||||
);
|
||||
this.disposables.push(
|
||||
this.listenAction('io.lotus.call_state', (evt) => {
|
||||
const data = (evt.detail as { data?: { participants?: unknown } } | undefined)?.data;
|
||||
@@ -706,6 +720,14 @@ export class CallEmbed {
|
||||
}
|
||||
|
||||
/** [lotus #2] Subscribe to io.lotus.call_state updates. Returns an unsubscribe. */
|
||||
/** [Gitea #17] Subscribe to the fork's io.lotus.request_state. Returns an unsubscribe. */
|
||||
public onForkStateRequest(cb: () => void): () => void {
|
||||
this.forkStateRequestListeners.add(cb);
|
||||
return () => {
|
||||
this.forkStateRequestListeners.delete(cb);
|
||||
};
|
||||
}
|
||||
|
||||
public onLotusCallState(cb: () => void): () => void {
|
||||
this.lotusCallStateListeners.add(cb);
|
||||
return () => {
|
||||
|
||||
Reference in New Issue
Block a user