feat(groups): audio and video calls in group chats; release v6.6.6
A group call is N-1 ordinary 1:1 calls, one to each other member, each riding the pairwise session that member already has — a transport a human already authenticated by comparing the safety code. No mixer, no SFU, no point at which two people's media meets anywhere but on a device. Call control is separate from call media, because the two reach different sets of people. Who opened a call, who joined and who left travels as group frames signed with the sender's group identity key, so it reaches members currently reachable only through a relay — and a relaying member can drop one but cannot write one. Media flows only where a direct link exists, so a member without one shows as connecting rather than being omitted. Frames carry a per-sender sequence checked before the action, so a captured leave cannot end a later call, and simultaneous calls converge on the lower random call id. One capture is shared across every leg rather than one getUserMedia per member, and legs answer without prompting: the flag permitting that is set only locally, only while this user is in the call, and cleared when they leave. UI: a gallery that sizes itself from the space it has, a spotlight view, an active-speaker indicator read from the waveform, and the call surface in the same visual language as the 1:1 one. Also in this commit, the v6.5.0 language-suggestion work that had not been pushed yet; its notes are in the changelog. And two fixes: the safety-code input asks for digits rather than text, and starting a new chat from inside a group no longer creates it behind the group where it cannot be seen — which had made it impossible to connect to anyone new, or to add anyone to a group, while a group was open. Claude-Session: https://claude.ai/code/session_01XSxAkET3hQTkYDQfbjCQwZ
This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
// An offer to switch language — never a redirect.
|
||||
//
|
||||
// The tempting version of this feature is to read navigator.language on load and send
|
||||
// the visitor to the matching locale. It breaks the site for search engines in a way
|
||||
// that is invisible from a browser: Googlebot crawls with a single language (en) from a
|
||||
// single place, so a language redirect answers every one of the thirteen locale URLs
|
||||
// with the English page. The twelve translations then have no indexable document of
|
||||
// their own, the hreflang cluster they are declared in contradicts what the crawler
|
||||
// actually receives, and the work of translating the site earns nothing. It is also
|
||||
// wrong for people: a link shared into a German group chat has to open in German for
|
||||
// everyone who follows it, including the person whose laptop is set to English.
|
||||
//
|
||||
// So the URL stays authoritative and this bar carries the whole feature. It appears only
|
||||
// when the page being read is not the language the visitor probably wants, it says so in
|
||||
// that language, and it offers a normal link. Dismissing it is remembered for good.
|
||||
//
|
||||
// Landing only, like the switcher: following the link is a navigation, and a navigation
|
||||
// during a session drops the peer connection.
|
||||
|
||||
import {
|
||||
LOCALE_META,
|
||||
dismissLocaleSuggestion,
|
||||
localeHref,
|
||||
localeSuggestionDismissed,
|
||||
rememberLocale,
|
||||
storedLocale,
|
||||
suggestedLocale,
|
||||
t,
|
||||
} from '../../i18n/index.js';
|
||||
import { prefersReducedMotion } from '../../ui/motion.js';
|
||||
|
||||
const LanguageSuggestion = () => {
|
||||
// Resolved once, at mount: the answer cannot change without a navigation, and
|
||||
// re-deriving it on every render would mean a storage read per render.
|
||||
const [target] = React.useState(() => {
|
||||
if (typeof window === 'undefined' || !window.location) return null;
|
||||
if (localeSuggestionDismissed()) return null;
|
||||
return suggestedLocale({
|
||||
pathname: window.location.pathname || '/',
|
||||
languages: window.navigator?.languages || [],
|
||||
stored: storedLocale(),
|
||||
});
|
||||
});
|
||||
const [gone, setGone] = React.useState(false);
|
||||
// Mounted invisible, then raised on the next frame, so the bar arrives rather than
|
||||
// being part of the first paint — the page's own content should land first.
|
||||
const [shown, setShown] = React.useState(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!target) return undefined;
|
||||
const id = setTimeout(() => setShown(true), 600);
|
||||
return () => clearTimeout(id);
|
||||
}, [target]);
|
||||
|
||||
if (!target || gone) return null;
|
||||
|
||||
const meta = LOCALE_META[target] || {};
|
||||
const dir = meta.dir === 'rtl' ? 'rtl' : 'ltr';
|
||||
const pathname = typeof window !== 'undefined' ? window.location.pathname : '/';
|
||||
const href = localeHref(target, pathname);
|
||||
const still = prefersReducedMotion();
|
||||
|
||||
const close = () => {
|
||||
dismissLocaleSuggestion();
|
||||
setGone(true);
|
||||
};
|
||||
|
||||
// The copy is in the language being offered — the point of the bar is to be readable
|
||||
// by someone who cannot read the page it sits on. `dir` and `lang` come with it, so
|
||||
// Arabic in this bar is laid out as Arabic even on the English page.
|
||||
const line = React.createElement('div', {
|
||||
key: 'line',
|
||||
lang: meta.htmlLang || target,
|
||||
dir,
|
||||
style: { fontSize: '13px', lineHeight: 1.45, color: '#cfcfd4', textAlign: 'start' },
|
||||
}, t('language.suggest.text', null, target));
|
||||
|
||||
const link = React.createElement('a', {
|
||||
key: 'cta',
|
||||
href,
|
||||
hrefLang: meta.htmlLang || target,
|
||||
lang: meta.htmlLang || target,
|
||||
dir,
|
||||
// Following the link is an explicit choice, so it is remembered — the bar has
|
||||
// then done its job and will not be offered again.
|
||||
onClick: () => { rememberLocale(target); dismissLocaleSuggestion(); },
|
||||
style: {
|
||||
display: 'inline-flex', alignItems: 'center', gap: '6px',
|
||||
padding: '7px 12px', borderRadius: '9px',
|
||||
border: '1px solid rgba(240,137,42,0.30)', background: 'rgba(240,137,42,0.12)',
|
||||
color: '#f0892a', fontSize: '12.5px', fontWeight: 600,
|
||||
textDecoration: 'none', whiteSpace: 'nowrap',
|
||||
},
|
||||
}, t('language.suggest.cta', null, target));
|
||||
|
||||
const dismiss = React.createElement('button', {
|
||||
key: 'dismiss',
|
||||
type: 'button',
|
||||
onClick: close,
|
||||
// Labelled in the offered language too: this button is for the same reader.
|
||||
'aria-label': t('language.suggest.dismiss', null, target),
|
||||
style: {
|
||||
padding: '7px 10px', borderRadius: '9px',
|
||||
border: '1px solid rgba(255,255,255,0.07)', background: 'rgba(255,255,255,0.02)',
|
||||
color: '#8a8a92', font: 'inherit', fontSize: '12.5px', fontWeight: 500,
|
||||
cursor: 'pointer', whiteSpace: 'nowrap',
|
||||
},
|
||||
}, t('language.suggest.dismiss', null, target));
|
||||
|
||||
return React.createElement('div', {
|
||||
// A region rather than a dialog: it interrupts nothing and takes no focus.
|
||||
role: 'region',
|
||||
'aria-label': t('language.label'),
|
||||
style: {
|
||||
// Bottom inline-start, because the install prompt owns the opposite corner.
|
||||
position: 'fixed', bottom: '24px', insetInlineStart: '24px', zIndex: 50,
|
||||
maxWidth: 'min(340px, calc(100vw - 48px))',
|
||||
display: 'flex', flexDirection: 'column', gap: '11px',
|
||||
padding: '14px 16px', borderRadius: '14px',
|
||||
border: '1px solid rgba(255,255,255,0.08)', background: '#161618',
|
||||
boxShadow: '0 16px 40px rgba(0,0,0,0.5)',
|
||||
opacity: shown ? 1 : 0,
|
||||
transform: shown || still ? 'none' : 'translateY(10px)',
|
||||
transition: still ? 'opacity .2s linear' : 'opacity .3s ease, transform .3s cubic-bezier(.2,.7,.3,1)',
|
||||
// Invisible and untouchable until raised, so it cannot swallow a tap during
|
||||
// the fade.
|
||||
pointerEvents: shown ? 'auto' : 'none',
|
||||
},
|
||||
}, [
|
||||
line,
|
||||
React.createElement('div', {
|
||||
key: 'actions',
|
||||
style: { display: 'flex', alignItems: 'center', gap: '8px' },
|
||||
}, [link, dismiss]),
|
||||
]);
|
||||
};
|
||||
|
||||
window.LanguageSuggestion = LanguageSuggestion;
|
||||
|
||||
export { LanguageSuggestion };
|
||||
Reference in New Issue
Block a user