mirror of
https://github.com/vector-im/element-web.git
synced 2026-04-01 19:51:58 +02:00
Merge branch 'develop' into recovery-to-backup
This commit is contained in:
commit
e70456db09
@ -508,15 +508,16 @@ test.describe("Element Call", () => {
|
||||
|
||||
await openAndJoinCall(page);
|
||||
await app.viewRoomByName("OtherRoom");
|
||||
const pipContainer = page.locator(".mx_WidgetPip");
|
||||
const pipContainer = page.getByTestId("widget-pip-container");
|
||||
|
||||
// We should have a PiP container here.
|
||||
await expect(pipContainer).toBeVisible();
|
||||
|
||||
// Leave the call.
|
||||
const overlay = page.locator(".mx_WidgetPip_overlay");
|
||||
await overlay.hover({ timeout: 2000 }); // Show the call footer.
|
||||
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
|
||||
const fakeWidget = page.locator('iframe[title="Element Call"]').contentFrame();
|
||||
|
||||
// await overlay.hover({ timeout: 2000 }); // Show the call footer.
|
||||
await fakeWidget.getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
// PiP container goes.
|
||||
await expect(pipContainer).not.toBeVisible();
|
||||
@ -541,15 +542,14 @@ test.describe("Element Call", () => {
|
||||
|
||||
await openAndJoinCall(page);
|
||||
await app.viewRoomByName("OtherRoom");
|
||||
const pipContainer = page.locator(".mx_WidgetPip");
|
||||
const pipContainer = page.getByTestId("widget-pip-container");
|
||||
|
||||
// We should have a PiP container here.
|
||||
await expect(pipContainer).toBeVisible();
|
||||
|
||||
// Leave the call.
|
||||
const overlay = page.locator(".mx_WidgetPip_overlay");
|
||||
await overlay.hover({ timeout: 2000 }); // Show the call footer.
|
||||
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
|
||||
const fakeWidget = page.locator('iframe[title="Element Call"]').contentFrame();
|
||||
await fakeWidget.getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
// PiP container goes.
|
||||
await expect(pipContainer).not.toBeVisible();
|
||||
@ -578,15 +578,16 @@ test.describe("Element Call", () => {
|
||||
await openAndJoinCall(page, true);
|
||||
|
||||
await app.viewRoomByName("OtherRoom");
|
||||
const pipContainer = page.locator(".mx_WidgetPip");
|
||||
const pipContainer = page.getByTestId("widget-pip-container");
|
||||
|
||||
// We should have a PiP container here.
|
||||
await expect(pipContainer).toBeVisible();
|
||||
|
||||
// Leave the call.
|
||||
const overlay = page.locator(".mx_WidgetPip_overlay");
|
||||
await overlay.hover({ timeout: 2000 }); // Show the call footer.
|
||||
await overlay.getByRole("button", { name: "Leave", exact: true }).click();
|
||||
const fakeWidget = page.locator('iframe[title="Element Call"]').contentFrame();
|
||||
|
||||
// await overlay.hover({ timeout: 2000 }); // Show the call footer.
|
||||
await fakeWidget.getByRole("button", { name: "Close", exact: true }).click();
|
||||
|
||||
// PiP container goes.
|
||||
await expect(pipContainer).not.toBeVisible();
|
||||
|
||||
@ -139,7 +139,7 @@ test.describe("Widget PIP", () => {
|
||||
);
|
||||
|
||||
// checks that pip window is opened
|
||||
await expect(page.locator(".mx_WidgetPip")).toBeVisible();
|
||||
await expect(page.getByTestId("widget-pip-container")).toBeVisible();
|
||||
|
||||
// checks that widget is opened in pip
|
||||
const iframe = page.frameLocator(`iframe[title="${DEMO_WIDGET_NAME}"]`);
|
||||
@ -155,7 +155,7 @@ test.describe("Widget PIP", () => {
|
||||
}
|
||||
|
||||
// checks that pip window is closed
|
||||
await expect(iframe.locator(".mx_WidgetPip")).not.toBeVisible();
|
||||
await expect(iframe.getByTestId("widget-pip-container")).not.toBeVisible();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@ -34,7 +34,6 @@
|
||||
@import "./components/views/location/_ZoomButtons.pcss";
|
||||
@import "./components/views/messages/_MBeaconBody.pcss";
|
||||
@import "./components/views/messages/shared/_MediaProcessingError.pcss";
|
||||
@import "./components/views/pips/_WidgetPip.pcss";
|
||||
@import "./components/views/polls/_PollOption.pcss";
|
||||
@import "./components/views/settings/_AddRemoveThreepids.pcss";
|
||||
@import "./components/views/settings/devices/_CurrentDeviceSection.pcss";
|
||||
|
||||
@ -1,73 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
$width: 320px;
|
||||
$height: 220px;
|
||||
|
||||
.mx_WidgetPip {
|
||||
width: $width;
|
||||
height: $height;
|
||||
}
|
||||
|
||||
.mx_WidgetPip_overlay {
|
||||
width: $width;
|
||||
height: $height;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
color: $call-primary-content;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mx_WidgetPip_header,
|
||||
.mx_WidgetPip_footer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
height: 60px;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
transition: opacity ease 0.15s;
|
||||
}
|
||||
|
||||
.mx_WidgetPip_overlay:not(:hover) {
|
||||
.mx_WidgetPip_header,
|
||||
.mx_WidgetPip_footer {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_WidgetPip_header {
|
||||
top: 0;
|
||||
padding: $spacing-12;
|
||||
display: flex;
|
||||
font-size: $font-12px;
|
||||
font-weight: var(--cpd-font-weight-semibold);
|
||||
background: linear-gradient(rgb(0, 0, 0, 0.9), rgb(0, 0, 0, 0));
|
||||
}
|
||||
|
||||
.mx_WidgetPip_backButton {
|
||||
height: $spacing-24;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: $spacing-12;
|
||||
|
||||
> .mx_Icon {
|
||||
color: $call-light-quaternary-content;
|
||||
padding: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.mx_WidgetPip_footer {
|
||||
bottom: 0;
|
||||
padding: $spacing-12 $spacing-8;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
align-items: flex-end;
|
||||
background: linear-gradient(rgb(0, 0, 0, 0), rgb(0, 0, 0, 0.9));
|
||||
}
|
||||
@ -7,10 +7,6 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
:root {
|
||||
--AppTile_mini-height: 220px;
|
||||
}
|
||||
|
||||
.mx_AppsDrawer {
|
||||
--minWidth: 240px; /* TODO this should be 300px but that's too large */
|
||||
|
||||
@ -168,11 +164,11 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
.mx_AppTile_mini {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: var(--AppTile_mini-height);
|
||||
}
|
||||
|
||||
.mx_AppTile .mx_AppTile_persistedWrapper,
|
||||
@ -276,8 +272,8 @@ Please see LICENSE files in the repository root for full details.
|
||||
&.mx_AppTileBody--large,
|
||||
&.mx_AppTileBody--mini {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
height: var(--AppTileBody-height);
|
||||
|
||||
iframe {
|
||||
border: none;
|
||||
@ -299,10 +295,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
}
|
||||
}
|
||||
|
||||
&.mx_AppTileBody--mini {
|
||||
--AppTileBody-height: var(--AppTile_mini-height);
|
||||
}
|
||||
|
||||
&.mx_AppTileBody--loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@ -18,10 +18,10 @@ const MOVING_AMT = 0.2;
|
||||
const SNAPPING_AMT = 0.1;
|
||||
|
||||
const PADDING = {
|
||||
top: 58,
|
||||
bottom: 58,
|
||||
left: 76,
|
||||
right: 8,
|
||||
top: 80,
|
||||
bottom: 87,
|
||||
left: 84,
|
||||
right: 16,
|
||||
};
|
||||
|
||||
/**
|
||||
@ -53,7 +53,7 @@ export default class PictureInPictureDragger extends React.Component<IProps> {
|
||||
private initX = 0;
|
||||
private initY = 0;
|
||||
private desiredTranslationX = UIStore.instance.windowWidth - PADDING.right - PIP_VIEW_WIDTH;
|
||||
private desiredTranslationY = UIStore.instance.windowHeight - PADDING.bottom - PIP_VIEW_HEIGHT;
|
||||
private desiredTranslationY = PADDING.top;
|
||||
private translationX = this.desiredTranslationX;
|
||||
private translationY = this.desiredTranslationY;
|
||||
private mouseHeld = false;
|
||||
|
||||
@ -6,9 +6,10 @@ SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Com
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type RefObject, type ReactNode, useRef } from "react";
|
||||
import React, { type RefObject, type ReactNode, useRef, useEffect } from "react";
|
||||
import { CallEvent, CallState, type MatrixCall } from "matrix-js-sdk/src/webrtc/call";
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { useCreateAutoDisposedViewModel, WidgetPipView } from "@element-hq/web-shared-components";
|
||||
|
||||
import LegacyCallView from "../views/voip/LegacyCallView";
|
||||
import LegacyCallHandler, { LegacyCallHandlerEvent } from "../../LegacyCallHandler";
|
||||
@ -21,7 +22,8 @@ import ActiveWidgetStore, { ActiveWidgetStoreEvent } from "../../stores/ActiveWi
|
||||
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { UPDATE_EVENT } from "../../stores/AsyncStore";
|
||||
import { SdkContextClass } from "../../contexts/SDKContext";
|
||||
import { WidgetPip } from "../views/pips/WidgetPip";
|
||||
import RoomAvatar from "../views/avatars/RoomAvatar";
|
||||
import { WidgetPipViewModel, type Props as WidgetPipViewModelProps } from "../../viewmodels/room/WidgetPipViewModel";
|
||||
|
||||
const SHOW_CALL_IN_STATES = [
|
||||
CallState.Connected,
|
||||
@ -46,7 +48,7 @@ interface IState {
|
||||
// they belong to
|
||||
secondaryCall: MatrixCall;
|
||||
|
||||
// widget candidate to be displayed in the pip view.
|
||||
// Widget candidate to be displayed in the PiP view.
|
||||
persistentWidgetId: string | null;
|
||||
persistentRoomId: string | null;
|
||||
showWidgetInPip: boolean;
|
||||
@ -251,7 +253,7 @@ class PipContainerInner extends React.Component<IProps, IState> {
|
||||
|
||||
if (this.state.showWidgetInPip && this.state.persistentWidgetId) {
|
||||
pipContent.push(({ onStartMoving }) => (
|
||||
<WidgetPip
|
||||
<WidgetPipWrappedView
|
||||
key="widget-pip"
|
||||
widgetId={this.state.persistentWidgetId!}
|
||||
room={MatrixClientPeg.safeGet().getRoom(this.state.persistentRoomId ?? undefined)!}
|
||||
@ -284,3 +286,30 @@ export const PipContainer: React.FC = () => {
|
||||
|
||||
return <PipContainerInner movePersistedElement={movePersistedElement} />;
|
||||
};
|
||||
|
||||
type Props = { viewingRoom: boolean } & WidgetPipViewModelProps;
|
||||
|
||||
/**
|
||||
* A wrapper for the WidgetPipView component.
|
||||
*
|
||||
* This exposes the new shared WidgetPipView with the same API as before and how
|
||||
* it is used in the PipContainerInner component.
|
||||
* @param props The same props the legacy WidgetPip was using.
|
||||
* @returns
|
||||
*/
|
||||
const WidgetPipWrappedView: React.FC<Props> = (props: Props) => {
|
||||
const vm = useCreateAutoDisposedViewModel(() => new WidgetPipViewModel(props));
|
||||
|
||||
useEffect(() => {
|
||||
// Use an effect to update viewingRoom. It is not required in the view but only in the view model.
|
||||
vm.setViewingRoom(props.viewingRoom);
|
||||
}, [vm, props.viewingRoom]);
|
||||
|
||||
return (
|
||||
<WidgetPipView
|
||||
vm={vm}
|
||||
// Props only used in the view and not the view model get passed directly.
|
||||
RoomAvatar={({ size }) => <RoomAvatar size={size} room={props.room} />}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
@ -1,134 +0,0 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2022 The Matrix.org Foundation C.I.C.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type FC, type RefObject, useCallback, useMemo } from "react";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { ArrowLeftIcon, EndCallIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
|
||||
import PersistentApp from "../elements/PersistentApp";
|
||||
import defaultDispatcher from "../../../dispatcher/dispatcher";
|
||||
import { type ViewRoomPayload } from "../../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { Action } from "../../../dispatcher/actions";
|
||||
import { useCallForWidget } from "../../../hooks/useCall";
|
||||
import WidgetStore from "../../../stores/WidgetStore";
|
||||
import { Container, WidgetLayoutStore } from "../../../stores/widgets/WidgetLayoutStore";
|
||||
import { useTypedEventEmitterState } from "../../../hooks/useEventEmitter";
|
||||
import Toolbar from "../../../accessibility/Toolbar";
|
||||
import { RovingAccessibleButton } from "../../../accessibility/RovingTabIndex";
|
||||
import { _t } from "../../../languageHandler";
|
||||
import { WidgetType } from "../../../widgets/WidgetType";
|
||||
import { WidgetMessagingStore } from "../../../stores/widgets/WidgetMessagingStore";
|
||||
import WidgetUtils from "../../../utils/WidgetUtils";
|
||||
import { ElementWidgetActions } from "../../../stores/widgets/ElementWidgetActions";
|
||||
import { type ButtonEvent } from "../elements/AccessibleButton";
|
||||
|
||||
interface Props {
|
||||
widgetId: string;
|
||||
room: Room;
|
||||
viewingRoom: boolean;
|
||||
onStartMoving: (e: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
movePersistedElement: RefObject<(() => void) | null>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A picture-in-picture view for a widget. Additional controls are shown if the
|
||||
* widget is a call of some sort.
|
||||
*/
|
||||
export const WidgetPip: FC<Props> = ({ widgetId, room, viewingRoom, onStartMoving, movePersistedElement }) => {
|
||||
const widget = useMemo(
|
||||
() => WidgetStore.instance.getApps(room.roomId).find((app) => app.id === widgetId)!,
|
||||
[room, widgetId],
|
||||
);
|
||||
|
||||
const roomName = useTypedEventEmitterState(
|
||||
room,
|
||||
RoomEvent.Name,
|
||||
useCallback(() => room.name, [room]),
|
||||
);
|
||||
|
||||
const call = useCallForWidget(widgetId, room.roomId);
|
||||
|
||||
const onBackClick = useCallback(
|
||||
(ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (call !== null) {
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
view_call: true,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
} else if (viewingRoom) {
|
||||
WidgetLayoutStore.instance.moveToContainer(room, widget, Container.Center);
|
||||
} else {
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
}
|
||||
},
|
||||
[room, call, widget, viewingRoom],
|
||||
);
|
||||
|
||||
const onLeaveClick = useCallback(
|
||||
(ev: ButtonEvent) => {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (call !== null) {
|
||||
call.disconnect().catch((e) => console.error("Failed to leave call", e));
|
||||
} else {
|
||||
// Assumed to be a Jitsi widget
|
||||
WidgetMessagingStore.instance
|
||||
.getMessagingForUid(WidgetUtils.getWidgetUid(widget))
|
||||
?.widgetApi?.transport.send(ElementWidgetActions.HangupCall, {})
|
||||
.catch((e) => console.error("Failed to leave Jitsi", e));
|
||||
}
|
||||
},
|
||||
[call, widget],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="mx_WidgetPip" onMouseDown={onStartMoving} onClick={onBackClick}>
|
||||
<PersistentApp
|
||||
persistentWidgetId={widgetId}
|
||||
persistentRoomId={room.roomId}
|
||||
pointerEvents="none"
|
||||
movePersistedElement={movePersistedElement}
|
||||
>
|
||||
<div onMouseDown={onStartMoving} className="mx_WidgetPip_overlay">
|
||||
<Toolbar className="mx_WidgetPip_header">
|
||||
<RovingAccessibleButton
|
||||
onClick={onBackClick}
|
||||
className="mx_WidgetPip_backButton"
|
||||
aria-label={_t("action|back")}
|
||||
>
|
||||
<ArrowLeftIcon className="mx_Icon mx_Icon_16" />
|
||||
{roomName}
|
||||
</RovingAccessibleButton>
|
||||
</Toolbar>
|
||||
{(call !== null || WidgetType.JITSI.matches(widget?.type)) && (
|
||||
<Toolbar className="mx_WidgetPip_footer">
|
||||
<RovingAccessibleButton
|
||||
onClick={onLeaveClick}
|
||||
title={_t("action|leave")}
|
||||
aria-label={_t("action|leave")}
|
||||
placement="top"
|
||||
>
|
||||
<EndCallIcon className="mx_Icon mx_Icon_24" />
|
||||
</RovingAccessibleButton>
|
||||
</Toolbar>
|
||||
)}
|
||||
</div>
|
||||
</PersistentApp>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -76,7 +76,7 @@ const ElementCallSwitch: React.FC<ElementCallSwitchProps> = ({ room }) => {
|
||||
return (
|
||||
<SettingsToggleInput
|
||||
name="element-call-switch"
|
||||
data-test-id="element-call-switch"
|
||||
data-testid="element-call-switch"
|
||||
label={_t("room_settings|voip|enable_element_call_label", { brand })}
|
||||
helpMessage={_t("room_settings|voip|enable_element_call_caption", {
|
||||
brand,
|
||||
|
||||
@ -27,11 +27,6 @@ export const useCall = (roomId: string): Call | null => {
|
||||
return call;
|
||||
};
|
||||
|
||||
export const useCallForWidget = (widgetId: string, roomId: string): Call | null => {
|
||||
const call = useCall(roomId);
|
||||
return call?.widget.id === widgetId ? call : null;
|
||||
};
|
||||
|
||||
export const useConnectionState = (call: Call | null): ConnectionState =>
|
||||
useTypedEventEmitterState(
|
||||
call ?? undefined,
|
||||
|
||||
@ -72,7 +72,7 @@
|
||||
"ignore": "Ignorere",
|
||||
"import": "Importér",
|
||||
"invite": "Invitere",
|
||||
"invite_to_space": "Invitér til gruppe",
|
||||
"invite_to_space": "Invitér til klynge",
|
||||
"invites_list": "Invitationer",
|
||||
"join": "Deltag",
|
||||
"learn_more": "Få mere at vide",
|
||||
@ -509,11 +509,11 @@
|
||||
"privacy": "Privatliv",
|
||||
"private": "Privat",
|
||||
"private_room": "Private rum",
|
||||
"private_space": "Privat gruppe",
|
||||
"private_space": "Privat klynge",
|
||||
"profile": "Profil",
|
||||
"public": "Offentlig",
|
||||
"public_room": "Offentlige rum",
|
||||
"public_space": "Offentlig gruppe",
|
||||
"public_space": "Offentlig klynge",
|
||||
"qr_code": "QR-kode",
|
||||
"random": "Tilfældig",
|
||||
"reactions": "Reaktioner",
|
||||
@ -531,8 +531,8 @@
|
||||
"setup_secure_messages": "Opsæt sikre beskeder",
|
||||
"show_more": "Vis mere",
|
||||
"someone": "Nogen",
|
||||
"space": "Gruppe",
|
||||
"spaces": "Grupper",
|
||||
"space": "Klynge",
|
||||
"spaces": "Klynger",
|
||||
"sticker": "Klistermærke",
|
||||
"stickerpack": "Klistermærkepakke",
|
||||
"success": "Succes",
|
||||
@ -547,7 +547,7 @@
|
||||
"unencrypted": "Ikke krypteret",
|
||||
"unmute": "Slå lyden til",
|
||||
"unnamed_room": "Unavngivet rum",
|
||||
"unnamed_space": "Unavngivet gruppe",
|
||||
"unnamed_space": "Unavngivet klynge",
|
||||
"unverified": "Ubekræftet",
|
||||
"updating": "Opdaterer...",
|
||||
"user": "Bruger",
|
||||
@ -625,7 +625,7 @@
|
||||
"encrypted_warning": "Du kan ikke deaktivere dette senere. Broer og de fleste bots vil ikke virke endnu.",
|
||||
"encryption_forced": "Din server kræver, at kryptering er aktiveret i private rum.",
|
||||
"encryption_label": "Aktivér end-to-end kryptering",
|
||||
"error_title": "Gruppen kunne ikke oprettes",
|
||||
"error_title": "Klyngen kunne ikke oprettes",
|
||||
"generic_error": "Serveren kan være utilgængelig, overbelastet, eller også har du fundet en fejl.",
|
||||
"join_rule_change_notice": "Du kan ændre dette nårsomhelst fra rummets indstillinger",
|
||||
"join_rule_invite": "Privat rum (kun tilgængeligt med invitation)",
|
||||
@ -633,9 +633,9 @@
|
||||
"join_rule_knock_label": "Alle kan anmode om at blive medlem, men administratorer eller moderatorer skal give adgang. Du kan ændre dette senere.",
|
||||
"join_rule_public_label": "Enhver vil kunne finde og tilslutte sig dette rum.",
|
||||
"join_rule_public_parent_space_label": "Hvem som helst vil være in stand til at finde og tilslutte sig dette rum, bortset fra medlemmer af <SpaceName/>",
|
||||
"join_rule_restricted": "Synlig for medlemmer af gruppen",
|
||||
"join_rule_restricted": "Standard",
|
||||
"join_rule_restricted_label": "Alle i <SpaceName/> vil kunne finde og tilslutte sig dette rum.",
|
||||
"name_validation_required": "Indtast venligst et navn for gruppen",
|
||||
"name_validation_required": "Indtast venligst et navn til rummet",
|
||||
"room_visibility_label": "Rummets synlighed",
|
||||
"title_private_room": "Opret et privat rum",
|
||||
"title_public_room": "Opret et offentligt rum",
|
||||
@ -644,40 +644,40 @@
|
||||
"unfederated": "Bloker alle, der ikke er en del af %(serverName)s fra nogensinde at deltage i dette rum.",
|
||||
"unfederated_label_default_off": "Du kan vælge at aktivere dette, hvis rummet kun skal bruges til samarbejde med interne teams på din hjemmeserver. Dette kan ikke ændres senere.",
|
||||
"unfederated_label_default_on": "Du kan vælge at deaktivere dette, hvis rummet skal bruges til samarbejde med eksterne teams, der har deres egen hjemmeserver. Dette kan ikke ændres senere.",
|
||||
"unsupported_version": "Serveren understøtter ikke den oplyste gruppeversion."
|
||||
"unsupported_version": "Serveren understøtter ikke den oplyste rum-version."
|
||||
},
|
||||
"create_space": {
|
||||
"add_details_prompt": "Tilføj nogle detaljer, for at hjælpe andre brugere med at genkende den.",
|
||||
"add_details_prompt_2": "Du kan ændre disse nårsomhelst",
|
||||
"add_existing_rooms_description": "Vælg rum eller samtaler at tilføje, dette er en gruppe kun for dig, ingen får besked om den. Du kan tilføje flere senere.",
|
||||
"add_existing_rooms_description": "Vælg rum eller samtaler at tilføje, dette er en klynge kun for dig, ingen får besked om den. Du kan tilføje flere senere.",
|
||||
"add_existing_rooms_heading": "Hvad ønsker du at organisere?",
|
||||
"address_label": "Adresse",
|
||||
"address_placeholder": "f.eks. min-gruppe",
|
||||
"address_placeholder": "f.eks. min-klynge",
|
||||
"creating": "Opretter...",
|
||||
"creating_rooms": "Opretter rum...",
|
||||
"done_action": "Gå til min gruppe",
|
||||
"done_action": "Gå til min klynge",
|
||||
"done_action_first_room": "Gå til min første rum",
|
||||
"explainer": "Grupper er en ny måde at samle rum og personer på. Hvilken type gruppe vil du gerne lave? Du kan ændre dette senere.",
|
||||
"failed_create_initial_rooms": "Kunne ikke oprette de første rum i gruppen",
|
||||
"failed_invite_users": "Det lykkedes ikke at invitere følgende brugere til din gruppe: %(csvUsers)s",
|
||||
"explainer": "Klynger er en ny måde at samle og finde rum og personer. Hvilken type klynge vil du gerne lave?",
|
||||
"failed_create_initial_rooms": "Kunne ikke oprette de første rum i klyngen",
|
||||
"failed_invite_users": "Det lykkedes ikke at invitere følgende brugere til din klynge: %(csvUsers)s",
|
||||
"invite_teammates_by_username": "Invitér med brugernavn",
|
||||
"invite_teammates_description": "Sørg for, at de rette mennesker har adgang. Du kan invitere flere senere.",
|
||||
"invite_teammates_heading": "Invitér dine holdkammerater",
|
||||
"inviting_users": "Inviterer...",
|
||||
"label": "Opret en gruppe",
|
||||
"name_required": "Indtast et navn til gruppen",
|
||||
"label": "Opret en klynge",
|
||||
"name_required": "Indtast et navn til klyngen",
|
||||
"personal_space": "Bare mig",
|
||||
"personal_space_description": "En privat gruppe til at organisere dine rum i",
|
||||
"personal_space_description": "En privat klynge til at organisere dine rum i",
|
||||
"private_description": "Kun for inviterede, bedst til dig selv eller lukkede fællesskaber",
|
||||
"private_heading": "Din private gruppe",
|
||||
"private_only_heading": "Din gruppe",
|
||||
"private_heading": "Din private klynge",
|
||||
"private_only_heading": "Din klynge",
|
||||
"private_personal_description": "Sørg for at de rette mennesker har adgang til %(name)s",
|
||||
"private_personal_heading": "Hvem arbejder du sammen med?",
|
||||
"private_space": "Mig og mine holdkammerater",
|
||||
"private_space_description": "En privat gruppe for dig selv og dine holdkammerater",
|
||||
"public_description": "Åben gruppe for alle, bedst til åbne fællesskaber",
|
||||
"public_heading": "Din offentlige gruppe",
|
||||
"search_public_button": "Søg efter offentlige grupper",
|
||||
"private_space_description": "En privat klynge til dig selv og dine holdkammerater",
|
||||
"public_description": "Alle kan deltage, bedst til åbne fællesskaber",
|
||||
"public_heading": "Din offentlige klynge",
|
||||
"search_public_button": "Søg efter offentlige klynger",
|
||||
"setup_rooms_community_description": "Lad os oprette et rum til hver af dem.",
|
||||
"setup_rooms_community_heading": "Hvad er nogle af de ting, du ønsker at diskutere i %(spaceName)s?",
|
||||
"setup_rooms_description": "Du kan også tilføje flere senere, herunder allerede eksisterende.",
|
||||
@ -687,13 +687,13 @@
|
||||
"share_heading": "Del %(name)s",
|
||||
"skip_action": "Spring over for nu",
|
||||
"subspace_adding": "Tilføjer...",
|
||||
"subspace_beta_notice": "Tilføj en gruppe til en gruppe som du styrer",
|
||||
"subspace_dropdown_title": "Opret en gruppe",
|
||||
"subspace_existing_space_prompt": "Ønsker du at tilføje en eksisterende gruppe i stedet?",
|
||||
"subspace_join_rule_invite_description": "Det er kun inviterede vil være i stand til at se og deltage i denne gruppe.",
|
||||
"subspace_join_rule_invite_only": "Privat gruppe (kun for inviterede)",
|
||||
"subspace_join_rule_label": "Gruppens synlighed",
|
||||
"subspace_join_rule_public_description": "Alle vil kunne finde og deltage i denne gruppe, ikke kun medlemmer af<SpaceName/> .",
|
||||
"subspace_beta_notice": "Tilføj en klynge til en klynge som du styrer",
|
||||
"subspace_dropdown_title": "Opret en klynge",
|
||||
"subspace_existing_space_prompt": "Ønsker du at tilføje en eksisterende klynge i stedet?",
|
||||
"subspace_join_rule_invite_description": "Det er kun inviterede, der vil være i stand til at se og deltage i denne klynge.",
|
||||
"subspace_join_rule_invite_only": "Privat klynge (kun for inviterede)",
|
||||
"subspace_join_rule_label": "Klyngens synlighed",
|
||||
"subspace_join_rule_public_description": "Alle vil kunne finde og deltage i denne klynge, ikke kun medlemmer af <SpaceName/> .",
|
||||
"subspace_join_rule_restricted_description": "Enhver i <SpaceName/> vil kunne finde og deltage."
|
||||
},
|
||||
"credits": {
|
||||
@ -819,7 +819,7 @@
|
||||
"emoji_picker": {
|
||||
"cancel_search_label": "Annullér søgning"
|
||||
},
|
||||
"empty_room": "Tom gruppe",
|
||||
"empty_room": "Tom klynge",
|
||||
"empty_room_was_name": "Tomt rum (var %(oldName)s)",
|
||||
"encryption": {
|
||||
"access_secret_storage_dialog": {
|
||||
@ -984,7 +984,7 @@
|
||||
},
|
||||
"error_database_closed_title": "%(brand)s holdt op med at fungere",
|
||||
"error_dialog": {
|
||||
"forget_room_failed": "Kunne ikke glemme gruppen %(errCode)s"
|
||||
"forget_room_failed": "Kunne ikke glemme rummet %(errCode)s"
|
||||
},
|
||||
"error_user_not_logged_in": "Brugeren er ikke logget ind",
|
||||
"event_preview": {
|
||||
@ -1127,10 +1127,10 @@
|
||||
"url_not_https": "Identitetsserverens URL skal være HTTPS"
|
||||
},
|
||||
"in_space": "I %(spaceName)s.",
|
||||
"in_space1_and_space2": "I grupperne %(space1Name)s og %(space2Name)s.",
|
||||
"in_space1_and_space2": "I klyngerne %(space1Name)s og %(space2Name)s.",
|
||||
"in_space_and_n_other_spaces": {
|
||||
"one": "I %(spaceName)s og én anden gruppe.",
|
||||
"other": "I %(spaceName)s og %(count)s andre grupper."
|
||||
"one": "I %(spaceName)s og én anden klynge.",
|
||||
"other": "I %(spaceName)s og %(count)s andre klynger."
|
||||
},
|
||||
"incompatible_browser": {
|
||||
"continue": "Fortsæt alligevel",
|
||||
@ -1164,31 +1164,31 @@
|
||||
"email_use_default_is": "Brug en identitetsserver til at invitere via e-mail. <default>Brug standardindstillingen (%(defaultIdentityServerName)s)</default> eller administrer den i <settings>Indstillinger</settings>.",
|
||||
"email_use_is": "Brug en identitetsserver til at invitere via e-mail. Bestem hvilken under <settings>Indstillinger</settings>.",
|
||||
"error_already_invited_room": "Brugeren er allerede inviteret til rummet",
|
||||
"error_already_invited_space": "Brugeren er allerede inviteret til gruppen",
|
||||
"error_already_invited_space": "Brugeren er allerede inviteret til klyngen",
|
||||
"error_already_joined_room": "Brugeren er allerede i rummet",
|
||||
"error_already_joined_space": "Brugeren er allerede i gruppen",
|
||||
"error_already_joined_space": "Brugeren er allerede i klyngen",
|
||||
"error_bad_state": "Brugerens spærring skal ophæves inden de kan blive inviteret.",
|
||||
"error_dm": "Vi kunne ikke oprette din samtale",
|
||||
"error_find_room": "Noget gik galt under forsøget på at invitere brugerne.",
|
||||
"error_find_user_description": "Følgende brugere findes muligvis ikke eller er ugyldige og kan ikke inviteres:%(csvNames)s",
|
||||
"error_find_user_title": "Kunne ikke finde følgende brugere",
|
||||
"error_invite": "Vi kunne ikke invitere disse brugere. Markér venligst de brugere, du vil invitere, og prøv igen.",
|
||||
"error_permissions_room": "Du har ikke tilladelse til at invitere personer til denne gruppe.",
|
||||
"error_permissions_space": "Du har ikke tilladelse til at invitere brugere til denne gruppe.",
|
||||
"error_permissions_room": "Du har ikke tilladelse til at invitere personer til dette rum.",
|
||||
"error_permissions_space": "Du har ikke tilladelse til at invitere brugere til denne klynge.",
|
||||
"error_profile_undisclosed": "Brugeren findes måske eller måske ikke",
|
||||
"error_transfer_multiple_target": "Et opkald kan kun viderestilles til én bruger.",
|
||||
"error_unfederated_space": "Denne gruppe er ikke fødereret. Du kan ikke invitere brugere fra eksterne servere.",
|
||||
"error_unfederated_space": "Denne klynge er ikke fødereret. Du kan ikke invitere brugere fra eksterne servere.",
|
||||
"error_unknown": "Ukendt serverfejl",
|
||||
"error_user_not_found": "Brugeren findes ikke",
|
||||
"error_version_unsupported_room": "Brugerens homeserver understøtter ikke versionen af denne gruppe.",
|
||||
"error_version_unsupported_space": "Brugerens hjemmeserver understøtter ikke denne version af gruppen.",
|
||||
"error_version_unsupported_room": "Brugerens homeserver understøtter ikke versionen af dette rum.",
|
||||
"error_version_unsupported_space": "Brugerens hjemmeserver understøtter ikke denne version af klyngen.",
|
||||
"failed_generic": "Operation mislykkedes",
|
||||
"failed_title": "Kunne ikke invitere",
|
||||
"invalid_address": "Ukendt adresse",
|
||||
"name_email_mxid_share_room": "Inviter andre ved hjælp af deres navn, e-mailadresse, brugernavn (f.eks. <userId/>) eller <a>del dette rum</a>.",
|
||||
"name_email_mxid_share_space": "Invitér nogen ved at bruge deres navn, e-mail adresse, brugernavn (som <userId/> eller <a>del denne gruppe</a>",
|
||||
"name_email_mxid_share_space": "Invitér nogen ved at bruge deres navn, e-mail adresse, brugernavn (som <userId/> eller <a>del denne klynge</a>",
|
||||
"name_mxid_share_room": "Inviter andre ved at bruge deres navn, brugernavn (f.eks. <userId/>) eller <a>del dette rum</a> .",
|
||||
"name_mxid_share_space": "Invitér nogen ved at bruge deres navn, brugernavn (som <userId/> eller <a>del denne gruppe</a>",
|
||||
"name_mxid_share_space": "Invitér nogen ved at bruge deres navn, brugernavn (som <userId/> eller <a>del denne klynge</a>",
|
||||
"recents_section": "Seneste samtaler",
|
||||
"room_failed_partial": "Vi har sendt til de andre, men nedenstående personer kunne ikke blive inviteret til <RoomName/>",
|
||||
"room_failed_partial_title": "Nogle invitationer kunne ikke blive sendt",
|
||||
@ -1256,9 +1256,9 @@
|
||||
"jump_room_search": "Gå til søgning i rum",
|
||||
"jump_to_read_marker": "Hop til den ældste, ulæste besked",
|
||||
"keyboard_shortcuts_tab": "Åbn denne indstillingsfane",
|
||||
"navigate_next_history": "Næste nyligt besøgte rum eller gruppe",
|
||||
"navigate_next_history": "Næste nyligt besøgte rum eller klynge",
|
||||
"navigate_next_message_edit": "Gå til næste besked at redigere",
|
||||
"navigate_prev_history": "Forrige nyligt besøgte rum eller gruppe",
|
||||
"navigate_prev_history": "Forrige nyligt besøgte rum eller klynge",
|
||||
"navigate_prev_message_edit": "Gå til forrige besked for at redigere",
|
||||
"next_room": "Næste rum eller samtale",
|
||||
"next_unread_room": "Næste ulæste rum eller samtale",
|
||||
@ -1279,11 +1279,11 @@
|
||||
"send_sticker": "Send et klistermærke",
|
||||
"shift": "Skift",
|
||||
"space": "Mellemrum",
|
||||
"switch_to_space": "Skift til gruppe efter nummer",
|
||||
"switch_to_space": "Skift til klynge efter nummer",
|
||||
"toggle_hidden_events": "Slå synligheden af skjulte begivenheder til/fra",
|
||||
"toggle_microphone_mute": "Slå mikrofonlyd fra",
|
||||
"toggle_right_panel": "Slå højre panel til/fra",
|
||||
"toggle_space_panel": "Slå panelet for grupper til/fra",
|
||||
"toggle_space_panel": "Slå panelet for klynger til/fra",
|
||||
"toggle_top_left_menu": "Slå menuen øverst til venstre til/fra",
|
||||
"toggle_webcam_mute": "Slå webcam til/fra",
|
||||
"upload_file": "Upload fil"
|
||||
@ -1319,7 +1319,7 @@
|
||||
"group_moderation": "Moderering",
|
||||
"group_profile": "Profil",
|
||||
"group_rooms": "Rum",
|
||||
"group_spaces": "Grupper",
|
||||
"group_spaces": "Klynger",
|
||||
"group_themes": "Temaer",
|
||||
"group_threads": "Tråde",
|
||||
"group_ui": "Brugergrænseflade",
|
||||
@ -1400,7 +1400,7 @@
|
||||
"leave_room_dialog": {
|
||||
"room_leave_admin_warning": "Du er den eneste administrator i dette rum. Hvis du forlader det, vil ingen være i stand til at ændre dets indstillinger eller foretage andre vigtige handlinger.",
|
||||
"room_leave_mod_warning": "Du er den eneste moderator i dette rum. Hvis du forlader det, vil ingen kunne ændre rummets indstillinger eller foretage andre vigtige handlinger.",
|
||||
"space_rejoin_warning": "Denne gruppe er ikke offentlig. Du har ikke mulighed for at tilslutte dig uden en invitation."
|
||||
"space_rejoin_warning": "Denne klynge er ikke offentlig. Du har ikke mulighed for at tilslutte dig uden en invitation."
|
||||
},
|
||||
"lightbox": {
|
||||
"rotate_left": "Roter mod venstre",
|
||||
@ -1729,7 +1729,7 @@
|
||||
"face_pile_tooltip_shortcut_joined": "Inklusive dig, %(commaSeparatedMembers)s",
|
||||
"failed_reject_invite": "Lykkedes ikke med at afvise invitation",
|
||||
"forget_room": "Glem dette rum",
|
||||
"forget_space": "Glem denne gruppe",
|
||||
"forget_space": "Glem denne klynge",
|
||||
"header": {
|
||||
"n_people_asking_to_join": {
|
||||
"one": "Beder om at deltage",
|
||||
@ -1739,10 +1739,10 @@
|
||||
},
|
||||
"header_face_pile_tooltip": "Brugere",
|
||||
"header_untrusted_label": "Upålidelig",
|
||||
"inaccessible": "Rummet eller gruppen er ikke tilgængelig lige nu.",
|
||||
"inaccessible": "Rummet eller klyngen er ikke tilgængelig lige nu.",
|
||||
"inaccessible_name": "%(roomName)s er ikke tilgængelig på nuværende tidspunkt.",
|
||||
"inaccessible_subtitle_1": "Prøv igen senere, eller bed en administrator af rummet om at tjekke, om du har adgang.",
|
||||
"inaccessible_subtitle_2": "%(errcode)s blev returneret, mens du forsøgte at få adgang til rummet eller gruppen. Hvis du mener, at denne besked vises ved en fejl, bedes du <issueLink>indsende en fejlrapport</issueLink>.",
|
||||
"inaccessible_subtitle_2": "%(errcode)s blev returneret, mens du forsøgte at få adgang til rummet eller klyngen. Hvis du mener, at denne besked vises ved en fejl, bedes du <issueLink>indsende en fejlrapport</issueLink>.",
|
||||
"intro": {
|
||||
"dm_caption": "Kun I to deltager i denne samtale, medmindre en af jer inviterer andre til at deltage.",
|
||||
"enable_encryption_prompt": "Aktivér kryptering i indstillingerne.",
|
||||
@ -1762,7 +1762,7 @@
|
||||
"invite_sent_to_email": "Denne invitation blev sendt til %(email)s",
|
||||
"invite_sent_to_email_room": "Denne invitation til %(roomName)s blev sendt til %(email)s",
|
||||
"invite_subtitle": "Inviteret af <userName/>",
|
||||
"invite_this_room": "Inviter til denne gruppe",
|
||||
"invite_this_room": "Inviter til dette rum",
|
||||
"invite_title": "Vil du være med i %(roomName)s?",
|
||||
"inviter_unknown": "Ukendt",
|
||||
"invites_you_text": "<inviter/> inviterer dig",
|
||||
@ -1773,14 +1773,14 @@
|
||||
"join_title_account": "Deltag i samtalen med en konto",
|
||||
"joining": "Tilslutter...",
|
||||
"joining_room": "Tilslutter til rummet...",
|
||||
"joining_space": "Tilslutter til gruppe...",
|
||||
"joining_space": "Tilslutter til klynge...",
|
||||
"jump_read_marker": "Gå til første ulæste besked.",
|
||||
"jump_to_bottom_button": "Rul ned til de seneste beskeder",
|
||||
"kick_reason": "Årsag: %(reason)s",
|
||||
"kicked_by": "Du blev fjernet af %(memberName)s",
|
||||
"kicked_from_room_by": "Du blev fjernet fra %(roomName)s af %(memberName)s",
|
||||
"knock_cancel_action": "Annuller anmodning",
|
||||
"knock_denied_subtitle": "Da du er blevet nægtet adgang, kan du ikke tilmelde dig igen, medmindre du bliver inviteret af gruppens administrator eller moderator.",
|
||||
"knock_denied_subtitle": "Da du er blevet nægtet adgang, kan du ikke tilmelde dig igen, medmindre du bliver inviteret af rummets administrator eller moderator.",
|
||||
"knock_denied_title": "Du er blevet nægtet adgang",
|
||||
"knock_message_field_placeholder": "Besked (valgfrit)",
|
||||
"knock_prompt": "Vil du bede om at deltage?",
|
||||
@ -1798,7 +1798,7 @@
|
||||
"no_peek_join_prompt": "%(roomName)s kan ikke forhåndsvises. Ønsker du at tilslutte dig det?",
|
||||
"no_peek_no_name_join_prompt": "Der er ingen forhåndsvisning tilgængelig, vil du tilmelde dig alligevel?",
|
||||
"not_found_subtitle": "Er du sikker på, at du er på det rigtige sted?",
|
||||
"not_found_title": "Rummet eller gruppen eksisterer ikke.",
|
||||
"not_found_title": "Rummet eller klyngen eksisterer ikke.",
|
||||
"not_found_title_name": "%(roomName)s findes ikke.",
|
||||
"peek_join_prompt": "Du ser en forhåndsvisning af %(roomName)s. Ønsker du at deltage i det?",
|
||||
"pinned_message_banner": {
|
||||
@ -1817,8 +1817,8 @@
|
||||
"one": "Du har %(count)s ulæst notifikation i en tidligere version af dette rum.",
|
||||
"other": "Du har %(count)s ulæste notifikationer i en tidligere version af dette rum."
|
||||
},
|
||||
"upgrade_error_description": "Dobbelt-tjek at din server understøtter den valgte gruppeversion og forsøg igen.",
|
||||
"upgrade_error_title": "Fejl under opgradering af gruppe",
|
||||
"upgrade_error_description": "Dobbelt-tjek at din server understøtter den valgte rumversion og forsøg igen.",
|
||||
"upgrade_error_title": "Fejl under opgradering af rum",
|
||||
"upgrade_warning_bar": "Opgradering af dette rum vil lukke den nuværende instans af rummet ned og oprette et opgraderet rum med samme navn.",
|
||||
"upgrade_warning_bar_admins": "Kun rummets administratorer vil se denne advarsel.",
|
||||
"upgrade_warning_bar_unstable": "Dette rum kører version <roomVersion />, som denne hjemmeserver har markeret som <i>ustabil</i>.",
|
||||
@ -1835,11 +1835,11 @@
|
||||
},
|
||||
"room_list": {
|
||||
"add_room_label": "Tilføj rum",
|
||||
"add_space_label": "Tilføj gruppe",
|
||||
"add_space_label": "Tilføj klynge",
|
||||
"breadcrumbs_empty": "Ingen nyligt besøgte rum",
|
||||
"breadcrumbs_label": "Nyligt besøgte rum",
|
||||
"failed_add_tag": "Kunne ikke tilføje tag(s): %(tagName)s til gruppen",
|
||||
"failed_remove_tag": "Kunne ikke fjerne tag(s): %(tagName)s fra gruppen",
|
||||
"failed_add_tag": "Kunne ikke tilføje tag(s): %(tagName)s til rummet",
|
||||
"failed_remove_tag": "Kunne ikke fjerne tag(s): %(tagName)s fra rummet",
|
||||
"failed_set_dm_tag": "Kunne ikke indstille tagget til direkte beskeder",
|
||||
"home_menu_label": "Hjemmeindstillinger",
|
||||
"join_public_room_label": "Deltag i offentligt rum",
|
||||
@ -1875,7 +1875,7 @@
|
||||
"error_upgrade_description": "Opgraderingen af rummet kunne ikke gennemføres",
|
||||
"error_upgrade_title": "Det lykkedes ikke at opgradere rummet",
|
||||
"information_section_room": "Information om rummet",
|
||||
"information_section_space": "Information om gruppen",
|
||||
"information_section_space": "Information om klyngen",
|
||||
"room_id": "Internt rum-ID",
|
||||
"room_predecessor": "Se ældre beskeder i %(roomName)s.",
|
||||
"room_upgrade_button": "Opgrader dette rum til den anbefalede rumversion",
|
||||
@ -1883,7 +1883,7 @@
|
||||
"room_version": "Version af rum:",
|
||||
"room_version_section": "Version af rum",
|
||||
"space_predecessor": "Se ældre version af %(spaceName)s.",
|
||||
"space_upgrade_button": "Opgradér denne gruppe til den anbefalede rumversion",
|
||||
"space_upgrade_button": "Opgradér denne klynge til den anbefalede rumversion",
|
||||
"unfederated": "Dette rum er ikke tilgængeligt via eksterne Matrix-servere.",
|
||||
"upgrade_button": "Opgrader dette rum til version %(version)s",
|
||||
"upgrade_dialog_description": "For at opgradere dette rum skal den nuværende instans af rummet lukkes, og der skal oprettes et nyt rum i stedet. For at give rummets medlemmer den bedst mulige oplevelse vil vi:",
|
||||
@ -1927,30 +1927,30 @@
|
||||
"canonical_alias_field_label": "Hovedadresse",
|
||||
"default_url_previews_off": "URL-forhåndsvisninger er som standard deaktiveret for deltagere i dette rum.",
|
||||
"default_url_previews_on": "URL-forhåndsvisninger er som standard aktiveret for deltagere i dette rum.",
|
||||
"description_space": "Redigér indstillinger for din gruppe.",
|
||||
"description_space": "Redigér indstillinger for din klynge.",
|
||||
"error_creating_alias_description": "Der opstod en fejl under oprettelsen af adressen. Den er muligvis ikke tilladt af serveren, eller der er opstået en midlertidig fejl.",
|
||||
"error_creating_alias_title": "Fejl ved oprettelse af adresse",
|
||||
"error_deleting_alias_description": "Der opstod en fejl ved fjernelsen af den adresse. Den findes muligvis ikke længere, eller der opstod en midlertidig fejl.",
|
||||
"error_deleting_alias_description_forbidden": "Du har ikke tilladelse til at slette adressen.",
|
||||
"error_deleting_alias_title": "Fejl ved fjernelse af adresse",
|
||||
"error_save_space_settings": "Kunne ikke gemme gruppens indstillinger.",
|
||||
"error_save_space_settings": "Kunne ikke gemme klyngens indstillinger.",
|
||||
"error_updating_alias_description": "Der opstod en fejl under opdateringen af rummets alternative adresser. Serveren tillader det muligvis ikke, eller der er opstået en midlertidig fejl.",
|
||||
"error_updating_canonical_alias_description": "Der opstod en fejl under opdatering af rummets primære adresse. Serveren tillader muligvis ikke opdateringen, eller der er opstået en midlertidig fejl.",
|
||||
"error_updating_canonical_alias_title": "Fejl ved opdatering af hovedadresse",
|
||||
"leave_space": "Forlad gruppe",
|
||||
"leave_space": "Forlad klynge",
|
||||
"local_alias_field_label": "Lokal adresse",
|
||||
"local_aliases_explainer_room": "Angiv adresser til dette rum, så brugerne kan finde det via din hjemmeserver (%(localDomain)s )",
|
||||
"local_aliases_explainer_space": "Angiv adresser til denne gruppe, så brugerne kan finde den via din hjemmeserver (%(localDomain)s )",
|
||||
"local_aliases_explainer_space": "Angiv adresser til denne klynge, så brugerne kan finde den via din hjemmeserver (%(localDomain)s )",
|
||||
"local_aliases_section": "Lokale adresser",
|
||||
"name_field_label": "Rummets navn",
|
||||
"new_alias_placeholder": "Ny offentliggjort adresse (f.eks. #alias:server)",
|
||||
"no_aliases_room": "Denne gruppe har ingen lokal adresse",
|
||||
"no_aliases_space": "Denne gruppe har ingen lokale adresser",
|
||||
"no_aliases_room": "Denne klynge har ingen lokale adresser",
|
||||
"no_aliases_space": "Denne klynge har ingen lokale adresser",
|
||||
"other_section": "Andre",
|
||||
"publish_toggle": "Offentliggør dette rum i %(domain)s's oversigt over rum?",
|
||||
"published_aliases_description": "For at offentliggøre en adresse skal den først indstilles som en lokal adresse.",
|
||||
"published_aliases_explainer_room": "Offentliggjorte adresser kan bruges af alle på enhver server til at deltage i dit rum.",
|
||||
"published_aliases_explainer_space": "Publicerede adresser kan bruges af alle til at tilslutte sig din gruppe.",
|
||||
"published_aliases_explainer_space": "Publicerede adresser kan bruges af alle til at tilslutte sig din klynge.",
|
||||
"published_aliases_section": "Offentliggjorte adresser",
|
||||
"save": "Gem ændringer",
|
||||
"topic_field_label": "Rummets emne",
|
||||
@ -1995,13 +1995,13 @@
|
||||
"m.call.member": "Deltag i %(brand)s opkald",
|
||||
"m.reaction": "Send reaktioner",
|
||||
"m.room.avatar": "Skift rummets avatar",
|
||||
"m.room.avatar_space": "Skift gruppens avatar",
|
||||
"m.room.avatar_space": "Skift klyngens avatar",
|
||||
"m.room.canonical_alias": "Skift hovedadresse for rummet",
|
||||
"m.room.canonical_alias_space": "Skift hovedadresse for gruppen",
|
||||
"m.room.canonical_alias_space": "Skift hovedadresse for klyngen",
|
||||
"m.room.encryption": "Aktivér kryptering af rummet",
|
||||
"m.room.history_visibility": "Skift historikkens synlighed",
|
||||
"m.room.name": "Skift rummets navn",
|
||||
"m.room.name_space": "Skift gruppens navn",
|
||||
"m.room.name_space": "Skift klyngens navn",
|
||||
"m.room.pinned_events": "Administrer fastgjorte begivenheder",
|
||||
"m.room.power_levels": "Skift tilladelser",
|
||||
"m.room.redaction": "Fjern beskeder sendt af mig",
|
||||
@ -2009,14 +2009,14 @@
|
||||
"m.room.tombstone": "Opgrader rummet",
|
||||
"m.room.topic": "Skift emne",
|
||||
"m.room.topic_space": "Lav ændringer i beskrivelsen",
|
||||
"m.space.child": "Administrer rummene i denne gruppe",
|
||||
"m.space.child": "Administrer rummene i denne klynge",
|
||||
"m.widget": "Rediger widgets",
|
||||
"muted_users_section": "Brugere, der er slået fra",
|
||||
"no_privileged_users": "Ingen brugere har særlige rettigheder i dette rum.",
|
||||
"notifications.room": "Underret alle",
|
||||
"permissions_section": "Tilladelser",
|
||||
"permissions_section_description_room": "Vælg de roller, der kræves for at ændre forskellige dele af rummet",
|
||||
"permissions_section_description_space": "Indstil de nødvendige roller der er påkrævet for at kunne ændre på forskellige dele af gruppen",
|
||||
"permissions_section_description_space": "Vælg de roller der er påkrævet for at kunne ændre på forskellige dele af klyngen",
|
||||
"privileged_users_section": "Privilegerede brugere",
|
||||
"redact": "Fjern beskeder sendt af andre",
|
||||
"send_event_type": "Sende %(eventType)s begivenheder",
|
||||
@ -2049,20 +2049,20 @@
|
||||
"join_rule_knock": "Bed om at deltage",
|
||||
"join_rule_knock_description": "Andre kan ikke tilslutte sig, medmindre der gives adgang.",
|
||||
"join_rule_public_description": "Enhver kan finde og tilslutte sig.",
|
||||
"join_rule_restricted": "Medlemmer af gruppen",
|
||||
"join_rule_restricted_description": "Alle i en gruppe kan finde og deltage. <a>Rediger hvilke grupper der har adgang her.</a>",
|
||||
"join_rule_restricted_description_active_space": "Enhver i <spaceName/> kan finde og deltage. Du kan også vælge andre grupper.",
|
||||
"join_rule_restricted_description_prompt": "Alle i en gruppe kan finde og deltage. Du kan vælge flere grupper.",
|
||||
"join_rule_restricted_description_spaces": "Grupper med adgang",
|
||||
"join_rule_restricted_dialog_description": "Bestem hvilke grupper der har adgang til dette rum. Hvis en gruppe bliver valgt, vil dens medlemmer kunne finde og deltage i <RoomName/>.",
|
||||
"join_rule_restricted_dialog_empty_warning": "Du er i færd med at fjerne alle grupper. Adgang vil falde tilbage på: \"kun for inviterede\".",
|
||||
"join_rule_restricted_dialog_filter_placeholder": "Søg i grupper",
|
||||
"join_rule_restricted_dialog_heading_known": "Andre grupper du kender",
|
||||
"join_rule_restricted_dialog_heading_other": "Andre grupper eller rum, du måske ikke kender til",
|
||||
"join_rule_restricted_dialog_heading_room": "Grupper, du kender, der indeholder dette rum",
|
||||
"join_rule_restricted_dialog_heading_space": "Grupper, du kender, der indeholder denne gruppe",
|
||||
"join_rule_restricted": "Medlemmer af klyngen",
|
||||
"join_rule_restricted_description": "Alle, der er i en klynge med tilladelse, kan finde og deltage. <a>Håndter klynger.</a>",
|
||||
"join_rule_restricted_description_active_space": "Enhver i <spaceName/> kan finde og deltage",
|
||||
"join_rule_restricted_description_prompt": "Alle i en klynge kan deltage.",
|
||||
"join_rule_restricted_description_spaces": "Autoriserede klynger",
|
||||
"join_rule_restricted_dialog_description": "Klynger, hvor medlemmer kan tilmelde sig <RoomName/> uden en invitation.",
|
||||
"join_rule_restricted_dialog_empty_warning": "Du er i færd med at fjerne alle uautoriserede klynger. Adgang vil falde tilbage på: \"kun for inviterede\".",
|
||||
"join_rule_restricted_dialog_filter_placeholder": "Søg i klynger",
|
||||
"join_rule_restricted_dialog_heading_known": "Dine klynger, der ikke indeholder dette rum",
|
||||
"join_rule_restricted_dialog_heading_other": "Andre klynger, du ikke er medlem af",
|
||||
"join_rule_restricted_dialog_heading_room": "Dine klynger, der indeholder dette rum",
|
||||
"join_rule_restricted_dialog_heading_space": "Klynger, du kender, der indeholder denne klynge",
|
||||
"join_rule_restricted_dialog_heading_unknown": "Det er sandsynligvis nogle, som andre rumadministratorer er en del af.",
|
||||
"join_rule_restricted_dialog_title": "Udvælg grupper",
|
||||
"join_rule_restricted_dialog_title": "Administrér klynger",
|
||||
"join_rule_restricted_n_more": {
|
||||
"one": "& %(count)s mere",
|
||||
"other": "& %(count)s mere"
|
||||
@ -2071,8 +2071,8 @@
|
||||
"one": "I øjeblikket har et rum adgang",
|
||||
"other": "I øjeblikket har %(count)s rum adgang"
|
||||
},
|
||||
"join_rule_restricted_upgrade_description": "Denne opgradering vil tillade medlemmer af udvalgte grupper at tilgå dette rum uden invitation.",
|
||||
"join_rule_restricted_upgrade_warning": "Dette rum er i nogle grupper, som du ikke er admin i. I de rum, vil det gamle rum stadig blive vist, men brugere vil blive foreslået at tilslutte sig det nye.",
|
||||
"join_rule_restricted_upgrade_description": "Denne opgradering vil tillade medlemmer af udvalgte klynger at tilgå dette rum uden invitation.",
|
||||
"join_rule_restricted_upgrade_warning": "Dette rum er i nogle klynger, som du ikke er admin for. I de klynger, vil det gamle rum stadig blive vist, men brugere vil blive foreslået at tilslutte sig det nye.",
|
||||
"join_rule_upgrade_awaiting_room": "Indlæser nyt rum",
|
||||
"join_rule_upgrade_required": "Opgradering påkrævet",
|
||||
"join_rule_upgrade_sending_invites": {
|
||||
@ -2080,13 +2080,13 @@
|
||||
"other": "Sender invitationer ... (%(progress)s ud af %(count)s)"
|
||||
},
|
||||
"join_rule_upgrade_updating_spaces": {
|
||||
"one": "Opdaterer gruppe...",
|
||||
"other": "Opdaterer grupper... (%(progress)s ud af %(count)s)"
|
||||
"one": "Opdaterer klynge...",
|
||||
"other": "Opdaterer klynger... (%(progress)s ud af %(count)s)"
|
||||
},
|
||||
"join_rule_upgrade_upgrading_room": "Opgradering af rum",
|
||||
"public_without_alias_warning": "For at kunne linke til dette rum, bedes du tilføje en adresse.",
|
||||
"publish_room": "Gør dette rum synligt i det offentlige rum-oversigt.",
|
||||
"publish_space": "Gør denne gruppe synlig i det offentlige register.",
|
||||
"publish_space": "Gør denne klynge synlig i det offentlige register.",
|
||||
"strict_encryption": "Send kun beskeder til verificerede brugere",
|
||||
"title": "Sikkerhed & Privatliv"
|
||||
},
|
||||
@ -2094,14 +2094,14 @@
|
||||
"upload_avatar_label": "Upload avatar",
|
||||
"visibility": {
|
||||
"alias_section": "Adresse",
|
||||
"error_failed_save": "Kunne ikke opdatere visningen af denne gruppe",
|
||||
"error_failed_save": "Kunne ikke opdatere visningen af denne klynge",
|
||||
"error_update_guest_access": "Det lykkedes ikke at opdatere gæsteadgangen til dette rum.",
|
||||
"error_update_history_visibility": "Det lykkedes ikke at opdatere synligheden af historikken i denne gruppe.",
|
||||
"guest_access_explainer": "Gæster kan tilslutte sig en gruppe uden at have en konto. Dette kan være nyttigt i offentlige grupper.",
|
||||
"error_update_history_visibility": "Det lykkedes ikke at opdatere synligheden af historikken i denne klynge.",
|
||||
"guest_access_explainer": "Gæster kan tilslutte sig en klynge uden at have en konto. Dette kan være nyttigt i offentlige klynger.",
|
||||
"guest_access_label": "Aktivér gæsteadgang",
|
||||
"history_visibility_anyone_space": "Forhåndsvis gruppe",
|
||||
"history_visibility_anyone_space_description": "Tillad andre at få forhåndsvist din gruppe før de tilslutter sig.",
|
||||
"history_visibility_anyone_space_recommendation": "Anbefalet for offentlige grupper.",
|
||||
"history_visibility_anyone_space": "Forhåndsvis klynge",
|
||||
"history_visibility_anyone_space_description": "Tillad andre at få forhåndsvist din klynge før de tilslutter sig.",
|
||||
"history_visibility_anyone_space_recommendation": "Anbefalet for offentlige klynger.",
|
||||
"title": "Synlighed"
|
||||
},
|
||||
"voip": {
|
||||
@ -2114,14 +2114,14 @@
|
||||
"room_summary_card_back_action_label": "Informationer om rummet",
|
||||
"scalar": {
|
||||
"error_create": "Kunne ikke lave widget.",
|
||||
"error_membership": "Du er ikke i denne gruppe.",
|
||||
"error_membership": "Du er ikke i dette rum.",
|
||||
"error_missing_room_id": "roomId mangler.",
|
||||
"error_missing_room_id_request": "Mangler room_id i forespørgsel",
|
||||
"error_missing_user_id_request": "Manglende user_id i forespørgsel",
|
||||
"error_permission": "Du har ikke tilladelse til at gøre dét i denne gruppe.",
|
||||
"error_permission": "Du har ikke tilladelse til at gøre dét i dette rum.",
|
||||
"error_power_level_invalid": "Magtniveau skal være positivt heltal.",
|
||||
"error_room_not_visible": "Gruppe %(roomId)s ikke synlig",
|
||||
"error_room_unknown": "Denne gruppe kan ikke genkendes.",
|
||||
"error_room_not_visible": "Rum %(roomId)s ikke synlig",
|
||||
"error_room_unknown": "Dette rum kan ikke genkendes.",
|
||||
"error_send_request": "Kunne ikke sende forespørgsel.",
|
||||
"failed_read_event": "Kunne ikke læse begivenheder",
|
||||
"failed_send_event": "Lykkedes ikke med at sende hændelse!"
|
||||
@ -2555,16 +2555,16 @@
|
||||
"sidebar": {
|
||||
"metaspaces_favourites_description": "Saml alle dine yndlingsrum og -personer på ét sted.",
|
||||
"metaspaces_home_all_rooms": "Vis alle rum",
|
||||
"metaspaces_home_all_rooms_description": "Vi alle dine rum i Hjem, selv hvis de er i en gruppe",
|
||||
"metaspaces_home_all_rooms_description": "Vi alle dine rum i Hjem, selv hvis de er i en klynge",
|
||||
"metaspaces_home_description": "Hjem er godt til at få et overblik over det hele.",
|
||||
"metaspaces_orphans": "Rum udenfor en gruppe",
|
||||
"metaspaces_orphans_description": "Saml alle dine grupper, der ikke er en del af et rum, på ét sted.",
|
||||
"metaspaces_orphans": "Rum udenfor en klynge",
|
||||
"metaspaces_orphans_description": "Saml alle dine rum, der ikke er en del af en klynge, på ét sted.",
|
||||
"metaspaces_people_description": "Saml alle dine forbindelser på ét sted.",
|
||||
"metaspaces_subsection": "Grupper at vise",
|
||||
"metaspaces_subsection": "Klynger at vise",
|
||||
"metaspaces_video_rooms": "Video-rum og konferencer",
|
||||
"metaspaces_video_rooms_description": "Saml alle private video-rum og konferencer.",
|
||||
"metaspaces_video_rooms_description_invite_extension": "I konferencer kan du invitere andre, der ikke er på matrix.",
|
||||
"spaces_explainer": "Grupper er en måde at samle rum og brugere på. Ud over de grupper du er i, kan du også benytte nogle nogle forhåndskonstruerede.",
|
||||
"spaces_explainer": "Klynger er en måde at samle rum og brugere på. Ud over de klynger du er i, kan du også benytte nogle nogle forhåndskonstruerede.",
|
||||
"title": "Sidemenu"
|
||||
},
|
||||
"start_automatically": {
|
||||
@ -2705,37 +2705,37 @@
|
||||
"one": "Tilføjer rum...",
|
||||
"other": "Tilføjer rum... (%(progress)s ud af %(count)s)"
|
||||
},
|
||||
"space_dropdown_label": "Valg af gruppe",
|
||||
"space_dropdown_label": "Valg af klynge",
|
||||
"space_dropdown_title": "Tilføj eksisterende rum",
|
||||
"subspace_moved_note": "Tilføjelse af grupper er flyttet"
|
||||
"subspace_moved_note": "Tilføjelse af klynger er flyttet"
|
||||
},
|
||||
"add_existing_subspace": {
|
||||
"create_button": "Opret en ny gruppe",
|
||||
"create_prompt": "Vil du tilføje en ny gruppe i stedet?",
|
||||
"filter_placeholder": "Søg efter grupper",
|
||||
"space_dropdown_title": "Tilføj eksisterende gruppe"
|
||||
"create_button": "Opret en ny klynge",
|
||||
"create_prompt": "Vil du tilføje en ny klynge i stedet?",
|
||||
"filter_placeholder": "Søg efter klynger",
|
||||
"space_dropdown_title": "Tilføj eksisterende klynge"
|
||||
},
|
||||
"context_menu": {
|
||||
"devtools_open_timeline": "Se rummets tidslinje (devtools)",
|
||||
"explore": "Udforsk rum",
|
||||
"home": "Hjem for gruppen",
|
||||
"home": "Hjem for klynge",
|
||||
"manage_and_explore": "Bestyr og udforsk rum",
|
||||
"options": "Valgmuligheder for gruppe"
|
||||
"options": "Valgmuligheder for klynge"
|
||||
},
|
||||
"failed_load_rooms": "Kunne ikke indlæse listen over rum.",
|
||||
"failed_remove_rooms": "Kunne ikke fjerne nogle rum. Prøv igen senere.",
|
||||
"incompatible_server_hierarchy": "Din server understøtter ikke visning af gruppehierarkier.",
|
||||
"incompatible_server_hierarchy": "Din server understøtter ikke visning af klyngehierakier.",
|
||||
"invite": "Invitér brugere",
|
||||
"invite_description": "Invitér med email eller brugernavn",
|
||||
"invite_link": "Del invitationslink",
|
||||
"joining_space": "Tilslutter",
|
||||
"landing_welcome": "Velkommen til <name/>",
|
||||
"leave_dialog_action": "Forlad gruppe",
|
||||
"leave_dialog_action": "Forlad klynge",
|
||||
"leave_dialog_description": "Du er i færd med at forlade <spaceName/>.",
|
||||
"leave_dialog_only_admin_room_warning": "Du er den eneste admin i nogle af de rum eller grupper som du ønsker at forlade. Hvis du forlader dem, står de uden nogen administratorer.",
|
||||
"leave_dialog_only_admin_warning": "Du er den eneste administrator af denne gruppe. Hvis du forlader den, medfører det, at ingen længere har kontrol over den.",
|
||||
"leave_dialog_only_admin_room_warning": "Du er den eneste admin i nogle af de rum eller klynger som du ønsker at forlade. Hvis du forlader dem, står de uden nogen administratorer.",
|
||||
"leave_dialog_only_admin_warning": "Du er den eneste administrator af denne klynge. Hvis du forlader den, medfører det, at ingen længere har kontrol over den.",
|
||||
"leave_dialog_option_all": "Forlad alle rum",
|
||||
"leave_dialog_option_intro": "Ønsker du at forlade rummene i denne gruppe?",
|
||||
"leave_dialog_option_intro": "Ønsker du at forlade rummene i denne klynge?",
|
||||
"leave_dialog_option_none": "Forlad ikke nogen rum",
|
||||
"leave_dialog_option_specific": "Forlad visse rum",
|
||||
"leave_dialog_public_rejoin_warning": "Du kan ikke tilslutte dig igen, medmindre du modtager en ny invitation.",
|
||||
@ -2744,17 +2744,17 @@
|
||||
"no_search_result_hint": "Du kan prøve med en anden søgning eller tjekke denne for stavefejl.",
|
||||
"preferences": {
|
||||
"sections_section": "Sektioner, der skal vises",
|
||||
"show_people_in_space": "Dette samler dine chats med medlemmer af denne gruppe. Hvis du slår denne funktion fra, bliver disse chats skjult fra din visning af %(spaceName)s."
|
||||
"show_people_in_space": "Dette samler dine chats med medlemmer af denne klynge. Hvis du slår denne funktion fra, bliver disse chats skjult fra din visning af %(spaceName)s."
|
||||
},
|
||||
"room_filter_placeholder": "Søg efter rum",
|
||||
"search_children": "Søg i %(spaceName)s",
|
||||
"search_placeholder": "Søg efter navne og beskrivelser",
|
||||
"select_room_below": "Vælg først et rum nedenfor",
|
||||
"share_public": "Del din offentlige gruppe",
|
||||
"share_public": "Del din offentlige klynge",
|
||||
"suggested": "Forslag",
|
||||
"suggested_tooltip": "Dette rum anbefales som et godt et at deltage i",
|
||||
"title_when_query_available": "Resultater",
|
||||
"title_when_query_unavailable": "Rum og grupper",
|
||||
"title_when_query_unavailable": "Rum og klynger",
|
||||
"unmark_suggested": "Markér som ikke foreslået",
|
||||
"user_lacks_permission": "Du har ikke tilladelse"
|
||||
},
|
||||
@ -2762,10 +2762,10 @@
|
||||
"title": "Indstillinger - %(spaceName)s"
|
||||
},
|
||||
"spaces": {
|
||||
"error_no_permission_add_room": "Du har ikke tilladelse til at tilføje rum til denne gruppe",
|
||||
"error_no_permission_add_space": "Du har ikke tilladelse til at tilføje andre grupper til denne gruppe.",
|
||||
"error_no_permission_create_room": "Du har ikke tilladelse til at oprette nye rum i denne gruppe",
|
||||
"error_no_permission_invite": "Du har ikke tilladelse til at invitere brugere til denne gruppe"
|
||||
"error_no_permission_add_room": "Du har ikke tilladelse til at tilføje rum til denne klynge",
|
||||
"error_no_permission_add_space": "Du har ikke tilladelse til at tilføje andre klynger til denne klynge",
|
||||
"error_no_permission_create_room": "Du har ikke tilladelse til at oprette nye rum i denne klynge",
|
||||
"error_no_permission_invite": "Du har ikke tilladelse til at invitere brugere til denne klynge"
|
||||
},
|
||||
"spotlight": {
|
||||
"public_rooms": {
|
||||
@ -2793,7 +2793,7 @@
|
||||
},
|
||||
"create_new_room_button": "Opret nyt rum",
|
||||
"failed_querying_public_rooms": "Kunne ikke forespørge på offentlige rum",
|
||||
"failed_querying_public_spaces": "Kunne ikke forespørge på offentlige grupper",
|
||||
"failed_querying_public_spaces": "Kunne ikke forespørge på offentlige klynger",
|
||||
"group_chat_section_title": "Andre indstillinger",
|
||||
"heading_with_query": "Brug \"%(query)s\" til at søge",
|
||||
"heading_without_query": "Søg efter",
|
||||
@ -2801,14 +2801,14 @@
|
||||
"keyboard_scroll_hint": "Brug <arrows/> til at rulle",
|
||||
"other_rooms_in_space": "Andre rum i %(spaceName)s",
|
||||
"public_rooms_label": "Offentlige rum",
|
||||
"public_spaces_label": "Offentlige grupper",
|
||||
"public_spaces_label": "Offentlige klynger",
|
||||
"recent_searches_section_title": "Seneste søgninger",
|
||||
"recently_viewed_section_title": "Senest besøgt",
|
||||
"remove_filter": "Fjern søgefilter for %(filter)s",
|
||||
"result_may_be_hidden_privacy_warning": "Nogle resultater kan være skjult af hensyn til privatlivets fred",
|
||||
"result_may_be_hidden_warning": "Nogle resultater kan være skjult",
|
||||
"search_dialog": "Dialogboks til søgning",
|
||||
"spaces_title": "Grupper, du er med i",
|
||||
"spaces_title": "Klynger, du er med i",
|
||||
"start_group_chat_button": "Start en fælles samtale"
|
||||
},
|
||||
"stickers": {
|
||||
@ -2968,12 +2968,12 @@
|
||||
},
|
||||
"m.room.canonical_alias": {
|
||||
"alt_added": {
|
||||
"one": "%(senderName)s tilføjede alternative adresser %(addresses)s til denne gruppe.",
|
||||
"other": "%(senderName)s tilføjede de alternative adresser %(addresses)s til denne gruppe."
|
||||
"one": "%(senderName)s tilføjede en alternativ adresse %(addresses)s til dette rum.",
|
||||
"other": "%(senderName)s tilføjede de alternative adresser %(addresses)s til denne dette rum."
|
||||
},
|
||||
"alt_removed": {
|
||||
"one": "%(senderName)s fjernede alternative adresser %(addresses)s til denne gruppe.",
|
||||
"other": "%(senderName)s fjernede de alternative adresser %(addresses)s til denne gruppe."
|
||||
"one": "%(senderName)s fjernede alternative adresser %(addresses)s til dette rum.",
|
||||
"other": "%(senderName)s fjernede de alternative adresser %(addresses)s til dette rum."
|
||||
},
|
||||
"changed": "%(senderName)s ændrede adresserne til dette rum.",
|
||||
"changed_alternative": "%(senderName)s ændrede de alternative adresser til dette rum.",
|
||||
@ -3318,7 +3318,7 @@
|
||||
"user_info": {
|
||||
"admin_tools_section": "Administrationsværktøjer",
|
||||
"ban_button_room": "Spær fra rum",
|
||||
"ban_button_space": "Spær fra gruppe",
|
||||
"ban_button_space": "Spær fra klynge",
|
||||
"ban_room_confirm_title": "Spær fra %(roomName)s",
|
||||
"ban_space_everything": "Spær dem fra alle de steder hvor jeg kan",
|
||||
"ban_space_specific": "Spær dem fra specifikke steder hvor jeg kan",
|
||||
@ -3331,7 +3331,7 @@
|
||||
"demote_self_confirm_title": "Nedgrader dig selv?",
|
||||
"disinvite_button_room": "Fjern invitation til rum",
|
||||
"disinvite_button_room_name": "Fjern invitation til %(roomName)s",
|
||||
"disinvite_button_space": "Tilbagekald invitation til gruppen",
|
||||
"disinvite_button_space": "Tilbagekald invitation til klyngen",
|
||||
"error_ban_user": "Kunne ikke spærre bruger",
|
||||
"error_deactivate": "Det lykkedes ikke at deaktivere brugeren",
|
||||
"error_kicking_user": "Brugeren kunne ikke fjernes",
|
||||
@ -3344,7 +3344,7 @@
|
||||
"jump_to_rr_button": "Gå til læsekvittering",
|
||||
"kick_button_room": "Fjern fra rummet",
|
||||
"kick_button_room_name": "Fjern fra %(roomName)s",
|
||||
"kick_button_space": "Spær fra gruppe",
|
||||
"kick_button_space": "Fjern fra klynge",
|
||||
"kick_button_space_everything": "Fjern dem fra alle de steder, hvor jeg har beføjelser til det",
|
||||
"kick_space_specific": "Fjern dem fra specifikke steder, hvor jeg har beføjelser til det",
|
||||
"kick_space_warning": "De vil stadig have adgang til de steder, du ikke er administrator for.",
|
||||
@ -3373,7 +3373,7 @@
|
||||
"room_unencrypted_detail": "I krypterede rum er dine beskeder sikret, og kun du og modtageren har de unikke nøgler til at låse dem op.",
|
||||
"share_button": "Del profil",
|
||||
"unban_button_room": "Fjern brugerens spærring fra rummet",
|
||||
"unban_button_space": "Fjern brugerens spærring fra gruppen",
|
||||
"unban_button_space": "Fjern brugerens spærring fra klyngen",
|
||||
"unban_room_confirm_title": "Fjern brugerens spærring fra %(roomName)s",
|
||||
"unban_space_everything": "Fjern spærring af dem alle steder hvor jeg kan",
|
||||
"unban_space_specific": "Fjern spærring af dem fra specifikke ting, jeg er i stand til",
|
||||
|
||||
131
apps/web/src/viewmodels/room/WidgetPipViewModel.tsx
Normal file
131
apps/web/src/viewmodels/room/WidgetPipViewModel.tsx
Normal file
@ -0,0 +1,131 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import {
|
||||
BaseViewModel,
|
||||
type WidgetPipViewSnapshot,
|
||||
type WidgetPipViewModel as WidgetPipViewModelInterface,
|
||||
} from "@element-hq/web-shared-components";
|
||||
import { type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import React from "react";
|
||||
|
||||
import type { RefObject, FC } from "react";
|
||||
import { Action } from "../../dispatcher/actions";
|
||||
import WidgetStore, { type IApp } from "../../stores/WidgetStore";
|
||||
import { CallStore, CallStoreEvent } from "../../stores/CallStore";
|
||||
import { type Call } from "../../models/Call";
|
||||
import defaultDispatcher from "../../dispatcher/dispatcher";
|
||||
import { type ViewRoomPayload } from "../../dispatcher/payloads/ViewRoomPayload";
|
||||
import { Container, WidgetLayoutStore } from "../../stores/widgets/WidgetLayoutStore";
|
||||
import PersistentApp from "../../components/views/elements/PersistentApp";
|
||||
|
||||
export interface Props {
|
||||
/**
|
||||
* The widgetId this widget PiP view is showing.
|
||||
*/
|
||||
widgetId: string;
|
||||
/**
|
||||
* The room this widget PiP view model is associated with.
|
||||
*/
|
||||
room: Room;
|
||||
/**
|
||||
* A callback which is called when a mouse event (most likely mouse down) occurs at the start of moving the PiP around.
|
||||
*/
|
||||
onStartMoving: (ev: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
/**
|
||||
* This callback ref will be used by the ViewModel once the view is moving.
|
||||
* Widgets might be implemented with a top-layer DOM tree path containing the widget iframe.
|
||||
* This allows moving the iframe around (PiP/in-room) without remounting it.
|
||||
* This callback allows any `PersistentApp` view/component to know when to update the iframe position of the widget.
|
||||
*/
|
||||
movePersistedElement: RefObject<(() => void) | null>;
|
||||
}
|
||||
|
||||
export class WidgetPipViewModel
|
||||
extends BaseViewModel<WidgetPipViewSnapshot, Props>
|
||||
implements WidgetPipViewModelInterface
|
||||
{
|
||||
/** The widget this view model uses for the PipView */
|
||||
private readonly widget: IApp;
|
||||
/**
|
||||
* The call associated with the widget (if the widget is a call widget)
|
||||
* For non-call widgets, this will be `null`.
|
||||
*/
|
||||
private call: Call | null;
|
||||
/** If the user is currently viewing the room associated with the PiP view (`this.props.room`) */
|
||||
private viewingRoom?: boolean;
|
||||
|
||||
public constructor(props: Props) {
|
||||
super(props, { widgetId: props.widgetId, roomName: props.room.name, roomId: props.room.roomId });
|
||||
this.widget = WidgetStore.instance.getApps(props.room.roomId).find((app) => app.id === this.props.widgetId)!;
|
||||
this.call = CallStore.instance.getCall(props.room.roomId) ?? null;
|
||||
this.onStartMoving = props.onStartMoving;
|
||||
|
||||
this.disposables.trackListener(props.room, RoomEvent.Name, this.onRoomName);
|
||||
this.disposables.trackListener(CallStore.instance, CallStoreEvent.Call, this.onCallChange);
|
||||
}
|
||||
|
||||
public onStartMoving: (ev: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
|
||||
/**
|
||||
* The view model needs to know if the room is currently being viewed.
|
||||
* @param viewing Whether we are currently viewing the room.
|
||||
*/
|
||||
public setViewingRoom(viewing: boolean): void {
|
||||
this.viewingRoom = viewing;
|
||||
}
|
||||
|
||||
public onBackClick(ev: React.MouseEvent<Element, MouseEvent>): void {
|
||||
ev.preventDefault();
|
||||
ev.stopPropagation();
|
||||
|
||||
if (this.call !== null) {
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.props.room.roomId,
|
||||
view_call: true,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
} else if (this.viewingRoom) {
|
||||
WidgetLayoutStore.instance.moveToContainer(this.props.room, this.widget, Container.Center);
|
||||
} else {
|
||||
defaultDispatcher.dispatch<ViewRoomPayload>({
|
||||
action: Action.ViewRoom,
|
||||
room_id: this.props.room.roomId,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The component to render as the persistent app by the WidgetPipView.
|
||||
* @param props A copy of the `PersistentApp` component's props.
|
||||
* @returns
|
||||
*/
|
||||
public persistentAppComponent: FC<
|
||||
Pick<React.ComponentProps<typeof PersistentApp>, "persistentWidgetId" | "persistentRoomId">
|
||||
> = (props) => {
|
||||
return (
|
||||
<PersistentApp
|
||||
persistentWidgetId={props.persistentWidgetId}
|
||||
persistentRoomId={props.persistentRoomId}
|
||||
movePersistedElement={this.props.movePersistedElement}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
private readonly onRoomName = (): void => {
|
||||
this.snapshot.merge({ roomName: this.props.room.name });
|
||||
};
|
||||
|
||||
private readonly onCallChange = (...args: unknown[]): void => {
|
||||
const [call, forRoomId] = args as [Call | null, string];
|
||||
if (forRoomId === this.props.room.roomId) {
|
||||
this.call = call?.widget.id === this.props.widgetId ? call : null;
|
||||
}
|
||||
};
|
||||
}
|
||||
@ -17,7 +17,7 @@ import {
|
||||
RoomStateEvent,
|
||||
type RoomMember,
|
||||
} from "matrix-js-sdk/src/matrix";
|
||||
import { Widget, type ClientWidgetApi } from "matrix-widget-api";
|
||||
import { Widget } from "matrix-widget-api";
|
||||
|
||||
import {
|
||||
useMockedCalls,
|
||||
@ -47,7 +47,6 @@ import { Container, WidgetLayoutStore } from "../../../../src/stores/widgets/Wid
|
||||
import WidgetStore from "../../../../src/stores/WidgetStore";
|
||||
import { WidgetType } from "../../../../src/widgets/WidgetType";
|
||||
import { SdkContextClass } from "../../../../src/contexts/SDKContext";
|
||||
import { ElementWidgetActions } from "../../../../src/stores/widgets/ElementWidgetActions";
|
||||
import { type WidgetMessaging } from "../../../../src/stores/widgets/WidgetMessaging";
|
||||
|
||||
jest.mock("../../../../src/stores/OwnProfileStore", () => ({
|
||||
@ -194,7 +193,7 @@ describe("PipContainer", () => {
|
||||
expect(screen.queryByRole("complementary")).toBeNull();
|
||||
});
|
||||
|
||||
it("shows an active call with back and leave buttons", async () => {
|
||||
it("shows an active call with back buttons", async () => {
|
||||
renderPip();
|
||||
|
||||
await withCall(async (call) => {
|
||||
@ -211,11 +210,6 @@ describe("PipContainer", () => {
|
||||
metricsTrigger: expect.any(String),
|
||||
});
|
||||
defaultDispatcher.unregister(dispatcherRef);
|
||||
|
||||
// The leave button should disconnect from the call
|
||||
const disconnectSpy = jest.spyOn(call, "disconnect");
|
||||
await user.click(screen.getByRole("button", { name: "Leave" }));
|
||||
expect(disconnectSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
@ -252,16 +246,7 @@ describe("PipContainer", () => {
|
||||
mockPlatformPeg({ supportsJitsiScreensharing: () => true });
|
||||
setUpRoomViewStore();
|
||||
viewRoom(room2.roomId);
|
||||
const widget = WidgetStore.instance.addVirtualWidget(
|
||||
{
|
||||
id: "1",
|
||||
creatorUserId: "@alice:example.org",
|
||||
type: WidgetType.JITSI.preferred,
|
||||
url: "https://meet.example.org",
|
||||
name: "Jitsi example",
|
||||
},
|
||||
room.roomId,
|
||||
);
|
||||
|
||||
renderPip();
|
||||
|
||||
await withWidget(async () => {
|
||||
@ -277,25 +262,6 @@ describe("PipContainer", () => {
|
||||
metricsTrigger: expect.any(String),
|
||||
});
|
||||
defaultDispatcher.unregister(dispatcherRef);
|
||||
|
||||
// The leave button should hangup the call
|
||||
const sendSpy = jest
|
||||
.fn<
|
||||
ReturnType<ClientWidgetApi["transport"]["send"]>,
|
||||
Parameters<ClientWidgetApi["transport"]["send"]>
|
||||
>()
|
||||
.mockResolvedValue({});
|
||||
const mockMessaging = {
|
||||
on: () => {},
|
||||
off: () => {},
|
||||
stop: () => {},
|
||||
widgetApi: {
|
||||
transport: { send: sendSpy },
|
||||
},
|
||||
} as unknown as WidgetMessaging;
|
||||
WidgetMessagingStore.instance.storeMessaging(new Widget(widget), room.roomId, mockMessaging);
|
||||
await user.click(screen.getByRole("button", { name: "Leave" }));
|
||||
expect(sendSpy).toHaveBeenCalledWith(ElementWidgetActions.HangupCall, {});
|
||||
});
|
||||
|
||||
WidgetStore.instance.removeVirtualWidget("1", room.roomId);
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and 2 should render both contents 1`] = `
|
||||
<div>
|
||||
<aside
|
||||
style="transform: translateX(680px) translateY(478px);"
|
||||
style="transform: translateX(672px) translateY(80px);"
|
||||
>
|
||||
<div>
|
||||
content 1
|
||||
@ -20,7 +20,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
|
||||
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rendering PiP content 2 should update the PiP content 1`] = `
|
||||
<div>
|
||||
<aside
|
||||
style="transform: translateX(680px) translateY(478px);"
|
||||
style="transform: translateX(672px) translateY(80px);"
|
||||
>
|
||||
<div>
|
||||
content 2
|
||||
@ -34,7 +34,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
|
||||
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 and rerendering PiP content 1 should not change the PiP content: pip-content-1 1`] = `
|
||||
<div>
|
||||
<aside
|
||||
style="transform: translateX(680px) translateY(478px);"
|
||||
style="transform: translateX(672px) translateY(80px);"
|
||||
>
|
||||
<div>
|
||||
content 1
|
||||
@ -46,7 +46,7 @@ exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 a
|
||||
exports[`PictureInPictureDragger when rendering the dragger with PiP content 1 should render the PiP content: pip-content-1 1`] = `
|
||||
<div>
|
||||
<aside
|
||||
style="transform: translateX(680px) translateY(478px);"
|
||||
style="transform: translateX(672px) translateY(80px);"
|
||||
>
|
||||
<div>
|
||||
content 1
|
||||
|
||||
@ -237,7 +237,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
||||
class="_content_193k4_38"
|
||||
>
|
||||
<p
|
||||
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _title_1rm46_17"
|
||||
class="_typography_6v6n8_153 _font-body-md-medium_6v6n8_60 _title_1xryk_24"
|
||||
id="_r_1c3_"
|
||||
>
|
||||
Could not start a chat with this user
|
||||
@ -247,7 +247,7 @@ exports[`RoomView for a local room in state ERROR should match the snapshot 1`]
|
||||
class="_actions_193k4_60"
|
||||
>
|
||||
<button
|
||||
class="_button_13vu4_8 _primaryAction_1rm46_13 _has-icon_13vu4_60"
|
||||
class="_button_13vu4_8 _primaryAction_1xryk_20 _has-icon_13vu4_60"
|
||||
data-kind="primary"
|
||||
data-size="sm"
|
||||
role="button"
|
||||
|
||||
108
apps/web/test/viewmodels/room/WidgetPip-test.ts
Normal file
108
apps/web/test/viewmodels/room/WidgetPip-test.ts
Normal file
@ -0,0 +1,108 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type MatrixClient, type Room, RoomEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type MockedObject } from "jest-mock";
|
||||
|
||||
import { mkRoom, stubClient } from "../../test-utils";
|
||||
import { WidgetPipViewModel } from "../../../src/viewmodels/room/WidgetPipViewModel";
|
||||
import WidgetStore, { type IApp } from "../../../src/stores/WidgetStore";
|
||||
import defaultDispatcher from "../../../src/dispatcher/dispatcher";
|
||||
import { Action } from "../../../src/dispatcher/actions";
|
||||
import { Container, WidgetLayoutStore } from "../../../src/stores/widgets/WidgetLayoutStore";
|
||||
import { CallStore, CallStoreEvent } from "../../../src/stores/CallStore";
|
||||
import { type Call } from "../../../src/models/Call";
|
||||
|
||||
const userId = "@example:example.org";
|
||||
const widgetId = "test-widget-id";
|
||||
|
||||
type BackClickEvent = Parameters<WidgetPipViewModel["onBackClick"]>[0];
|
||||
|
||||
const createBackClickEvent = (): BackClickEvent =>
|
||||
({
|
||||
preventDefault: jest.fn(),
|
||||
stopPropagation: jest.fn(),
|
||||
}) as unknown as BackClickEvent;
|
||||
|
||||
describe("WidgetPipViewModel", () => {
|
||||
let client: MockedObject<MatrixClient>;
|
||||
let vm: WidgetPipViewModel;
|
||||
let room: MockedObject<Room>;
|
||||
let widget: IApp;
|
||||
|
||||
beforeEach(() => {
|
||||
client = stubClient() as MockedObject<MatrixClient>;
|
||||
room = mkRoom(client, "!example");
|
||||
widget = {
|
||||
id: widgetId,
|
||||
roomId: room.roomId,
|
||||
creatorUserId: userId,
|
||||
type: "m.custom",
|
||||
name: "Test Widget",
|
||||
data: {},
|
||||
} as unknown as IApp;
|
||||
jest.spyOn(WidgetStore.instance, "getApps").mockReturnValue([widget]);
|
||||
|
||||
vm = new WidgetPipViewModel({
|
||||
room,
|
||||
widgetId,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vm.dispose();
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("updates room name", () => {
|
||||
room.name = "New Room Name";
|
||||
room.emit(RoomEvent.Name, room);
|
||||
expect(vm.getSnapshot().roomName).toBe("New Room Name");
|
||||
});
|
||||
|
||||
it("updates onBackClick if call changes", () => {
|
||||
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
|
||||
|
||||
vm.onBackClick(createBackClickEvent());
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
dispatchSpy.mockClear();
|
||||
|
||||
const call = { widget: { id: widgetId } } as unknown as Call;
|
||||
CallStore.instance.emit(CallStoreEvent.Call, call, room.roomId);
|
||||
|
||||
vm.onBackClick(createBackClickEvent());
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
view_call: true,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
});
|
||||
|
||||
it("updates onBackClick if viewingRoom changes", () => {
|
||||
const dispatchSpy = jest.spyOn(defaultDispatcher, "dispatch").mockImplementation(() => {});
|
||||
const moveSpy = jest.spyOn(WidgetLayoutStore.instance, "moveToContainer").mockImplementation(() => {});
|
||||
|
||||
vm.setViewingRoom(true);
|
||||
vm.onBackClick(createBackClickEvent());
|
||||
expect(moveSpy).toHaveBeenCalledWith(room, widget, Container.Center);
|
||||
|
||||
moveSpy.mockClear();
|
||||
vm.setViewingRoom(false);
|
||||
vm.onBackClick(createBackClickEvent());
|
||||
expect(dispatchSpy).toHaveBeenCalledWith({
|
||||
action: Action.ViewRoom,
|
||||
room_id: room.roomId,
|
||||
metricsTrigger: "WebFloatingCallWindow",
|
||||
});
|
||||
expect(moveSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 26 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
@ -3,6 +3,7 @@
|
||||
"seek_bar_label": "Audio seek bar"
|
||||
},
|
||||
"action": {
|
||||
"back": "Back",
|
||||
"delete": "Delete",
|
||||
"dismiss": "Dismiss",
|
||||
"edit": "Edit",
|
||||
|
||||
@ -27,6 +27,7 @@ export * from "./message-body/TimelineSeparator/";
|
||||
export * from "./pill-input/Pill";
|
||||
export * from "./pill-input/PillInput";
|
||||
export * from "./room/RoomStatusBar";
|
||||
export * from "./room/WidgetPip";
|
||||
export * from "./profile/DisambiguatedProfile";
|
||||
export * from "./room/HistoryVisibilityBadge";
|
||||
export * from "./rich-list/RichItem";
|
||||
|
||||
@ -1,3 +1,10 @@
|
||||
/*
|
||||
* Copyright (c) 2025 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.container {
|
||||
color: var(--cpd-color-text-primary);
|
||||
svg {
|
||||
|
||||
@ -0,0 +1,35 @@
|
||||
/*
|
||||
Copyright (c) 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
.container {
|
||||
width: 304px; /* hardcoded to match figma -> might become a resizable pip in the future */
|
||||
height: 278px; /* hardcoded to match figma -> might become a resizable pip in the future */
|
||||
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: var(--cpd-space-3x);
|
||||
background: var(--cpd-color-bg-canvas-default);
|
||||
border: 1px solid var(--cpd-color-border-interactive-secondary);
|
||||
border-radius: var(--cpd-space-6x);
|
||||
gap: var(--cpd-space-2x);
|
||||
box-shadow: 0 0 var(--cpd-space-2x) rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.header {
|
||||
top: 0;
|
||||
gap: var(--cpd-space-2x);
|
||||
display: flex;
|
||||
font: var(--cpd-font-body-md-semibold);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.roundedCornerContainer {
|
||||
border-radius: var(--cpd-space-4x);
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-grow: 1;
|
||||
}
|
||||
@ -0,0 +1,89 @@
|
||||
/*
|
||||
Copyright (c) 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import { type Meta, type StoryFn } from "@storybook/react-vite";
|
||||
import React, { type JSX } from "react";
|
||||
import { fn } from "storybook/test";
|
||||
|
||||
import { useMockedViewModel } from "../../viewmodel";
|
||||
import { WidgetPipView, type WidgetPipViewActions, type WidgetPipViewSnapshot } from "./WidgetPipView";
|
||||
import { withViewDocs } from "../../../.storybook/withViewDocs";
|
||||
|
||||
type WidgetPipViewProps = WidgetPipViewSnapshot & WidgetPipViewActions;
|
||||
|
||||
// Helper components that are provided outside of this storybook
|
||||
const RoomAvatarMock: React.FC = () => (
|
||||
<div style={{ width: 20, height: 20, borderRadius: "50%", backgroundColor: "grey" }} />
|
||||
);
|
||||
const PersistentAppMock: React.FC = () => <div style={{ backgroundColor: "grey", flexGrow: 1 }} />;
|
||||
|
||||
const WidgetPipViewWrapperImpl = ({
|
||||
onBackClick,
|
||||
persistentAppComponent,
|
||||
onStartMoving,
|
||||
...rest
|
||||
}: WidgetPipViewProps): JSX.Element => {
|
||||
const vm = useMockedViewModel(rest, {
|
||||
onBackClick,
|
||||
persistentAppComponent,
|
||||
onStartMoving,
|
||||
});
|
||||
return <WidgetPipView vm={vm} RoomAvatar={RoomAvatarMock} />;
|
||||
};
|
||||
|
||||
const WidgetPipViewWrapper = withViewDocs(WidgetPipViewWrapperImpl, WidgetPipView);
|
||||
|
||||
export default {
|
||||
title: "room/WidgetPipView",
|
||||
component: WidgetPipViewWrapper,
|
||||
tags: ["autodocs"],
|
||||
argTypes: {},
|
||||
args: {
|
||||
widgetId: "xyz",
|
||||
roomId: "roomId",
|
||||
roomName: "Room Name",
|
||||
onBackClick: fn(),
|
||||
persistentAppComponent: PersistentAppMock,
|
||||
onStartMoving: fn(),
|
||||
},
|
||||
parameters: {
|
||||
design: {
|
||||
type: "figma",
|
||||
url: "https://www.figma.com/design/aOEkaJtaBmPy058V7uoqVr/Element-Call-Updates---Q1-2026--New-?node-id=21-31333&p=f&t=zBuFi63PKdQ0Nhab-0",
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof WidgetPipViewWrapper>;
|
||||
|
||||
const Template: StoryFn<typeof WidgetPipViewWrapper> = (args) => <WidgetPipViewWrapper {...args} />;
|
||||
|
||||
/**
|
||||
* Rendered when using a widget with just a grey background.
|
||||
*/
|
||||
export const WithGreyWidget = Template.bind({});
|
||||
WithGreyWidget.args = {};
|
||||
|
||||
/**
|
||||
* Rendered when using a transparent background widget like Element Call.
|
||||
*/
|
||||
export const WithElementCallWidgetMock = Template.bind({});
|
||||
const CallPill: React.FC = () => (
|
||||
<div style={{ borderRadius: "50%", width: "50px", height: "50px", backgroundColor: "grey" }} />
|
||||
);
|
||||
WithElementCallWidgetMock.args = {
|
||||
roomName: "Element Call Room",
|
||||
persistentAppComponent: () => (
|
||||
<div style={{ flexGrow: 1, display: "flex", flexDirection: "column" }}>
|
||||
<div style={{ flexGrow: 4, backgroundColor: "gray", borderRadius: "24px" }} />
|
||||
<div style={{ display: "flex", justifyContent: "space-between", margin: "10px" }}>
|
||||
<CallPill />
|
||||
<CallPill />
|
||||
<CallPill />
|
||||
<CallPill />
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
};
|
||||
@ -0,0 +1,51 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React from "react";
|
||||
import { render } from "@test-utils";
|
||||
import { composeStories } from "@storybook/react-vite";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { describe, it, vi, expect } from "vitest";
|
||||
import { getByTestId } from "storybook/test";
|
||||
|
||||
import * as stories from "./WidgetPipView.stories.tsx";
|
||||
|
||||
const { WithGreyWidget } = composeStories(stories);
|
||||
|
||||
describe("WidgetPipView", () => {
|
||||
it("renders with gray widget", () => {
|
||||
const { container } = render(<WithGreyWidget />);
|
||||
expect(container).toMatchSnapshot();
|
||||
});
|
||||
it("detects back click action", async () => {
|
||||
const onBackClick = vi.fn();
|
||||
const { container, getByRole } = render(<WithGreyWidget onBackClick={onBackClick} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
|
||||
const button = getByRole("button", { name: "Back" });
|
||||
await userEvent.click(button);
|
||||
expect(onBackClick).toHaveBeenCalled();
|
||||
});
|
||||
it("detects double click triggers back", async () => {
|
||||
const onBackClick = vi.fn();
|
||||
const { container } = render(<WithGreyWidget onStartMoving={onBackClick} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
|
||||
const pipContainer = getByTestId(container, "widget-pip-container");
|
||||
await userEvent.dblClick(pipContainer);
|
||||
expect(onBackClick).toHaveBeenCalled();
|
||||
});
|
||||
it("detects on mouse down for drag", async () => {
|
||||
const onStartMoving = vi.fn();
|
||||
const { container } = render(<WithGreyWidget onStartMoving={onStartMoving} />);
|
||||
expect(container).toMatchSnapshot();
|
||||
|
||||
const pipContainer = getByTestId(container, "widget-pip-container");
|
||||
await userEvent.click(pipContainer);
|
||||
expect(onStartMoving).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
109
packages/shared-components/src/room/WidgetPip/WidgetPipView.tsx
Normal file
109
packages/shared-components/src/room/WidgetPip/WidgetPipView.tsx
Normal file
@ -0,0 +1,109 @@
|
||||
/*
|
||||
Copyright (c) 2026 Element Creations Ltd.
|
||||
|
||||
SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
import React, { type FC } from "react";
|
||||
import { ChevronLeftIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
|
||||
import { IconButton } from "@vector-im/compound-web";
|
||||
|
||||
import styles from "./WidgetPipView.module.css";
|
||||
import { useViewModel, type ViewModel } from "../../viewmodel";
|
||||
import { useI18n } from "../..";
|
||||
|
||||
export interface WidgetPipViewActions {
|
||||
/**
|
||||
* Call this once the back button is clicked in the pip view.
|
||||
* The view model will handle navigating back to the associated room.
|
||||
* @param ev The mouse event that triggered the back click.
|
||||
*/
|
||||
onBackClick: (ev: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
/**
|
||||
* The view model exposes the `<PersistentApp />` component via this action.
|
||||
* `PersistentApp` is not available in shared components.
|
||||
* It can be any React component that renders a widget.
|
||||
* It will be mounted inside the PipView.
|
||||
*/
|
||||
persistentAppComponent: React.FC<{
|
||||
persistentWidgetId: string;
|
||||
persistentRoomId: string;
|
||||
}>;
|
||||
/**
|
||||
* Action that needs to be called when the pip view starts to get dragged.
|
||||
* @param ev The mouse event that triggered the drag start.
|
||||
*/
|
||||
onStartMoving: (ev: React.MouseEvent<Element, MouseEvent>) => void;
|
||||
}
|
||||
|
||||
export interface WidgetPipViewSnapshot {
|
||||
/**
|
||||
* The widget ID this view is rendering.
|
||||
*/
|
||||
widgetId: string;
|
||||
/**
|
||||
* The room name the Pip View should use in the header.
|
||||
*/
|
||||
roomName: string;
|
||||
/**
|
||||
* The room ID this PiP view’s widget is associated with.
|
||||
*/
|
||||
roomId: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* The view model for the widget PiP view.
|
||||
*/
|
||||
export type WidgetPipViewModel = ViewModel<WidgetPipViewSnapshot> & WidgetPipViewActions;
|
||||
|
||||
export interface WidgetPipViewProps {
|
||||
/**
|
||||
* The WidgetPipViewModel to expose the WidgetPipViewSnapshot and to:
|
||||
* - handling the back button callback.
|
||||
* - exposing the persistentApp react component to the view.
|
||||
*/
|
||||
vm: WidgetPipViewModel;
|
||||
/**
|
||||
* The avatar is passed as a React component.
|
||||
* This allows any avatar implementation to be used in this view (like RoomAvatar).
|
||||
*/
|
||||
// In the future the avatar component can/should also become a shared component.
|
||||
// It would then be accessible in the shared component package and we could remove this prop.
|
||||
RoomAvatar: React.FC<{ size: string }>;
|
||||
}
|
||||
|
||||
/**
|
||||
* A picture-in-picture view for a widget. Additional controls are shown if the
|
||||
* widget represents a call.
|
||||
*/
|
||||
export const WidgetPipView: FC<WidgetPipViewProps> = ({ vm, RoomAvatar }) => {
|
||||
const snapshot = useViewModel(vm);
|
||||
const { translate: _t } = useI18n();
|
||||
return (
|
||||
// The interaction where we use the `onMouseDown` handler is only useful for dragging the widget around,
|
||||
// which is not possible via the keyboard. The outcome of this interaction can only be changed
|
||||
// if the user interacts with a mouse. Hence there is no use in providing an accessible alternative.
|
||||
// In the future we might consider introducing alternative shortcuts for moving the PiP around
|
||||
// with the keyboard.
|
||||
// eslint-disable-next-line jsx-a11y/no-static-element-interactions
|
||||
<div data-testid="widget-pip-container" className={styles.container} onMouseDown={vm.onStartMoving}>
|
||||
<div className={styles.header}>
|
||||
<IconButton
|
||||
size="28px"
|
||||
data-testid="base-card-back-button"
|
||||
onClick={(ev) => vm.onBackClick(ev)}
|
||||
tooltip={_t("action|back")}
|
||||
kind="secondary"
|
||||
>
|
||||
<ChevronLeftIcon />
|
||||
</IconButton>
|
||||
<RoomAvatar size="20px" />
|
||||
{snapshot.roomName}
|
||||
</div>
|
||||
<div className={styles.roundedCornerContainer}>
|
||||
<vm.persistentAppComponent persistentWidgetId={snapshot.widgetId} persistentRoomId={snapshot.roomId} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
@ -0,0 +1,205 @@
|
||||
// Vitest Snapshot v1, https://vitest.dev/guide/snapshot.html
|
||||
|
||||
exports[`WidgetPipView > detects back click action 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
data-testid="widget-pip-container"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_6_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="secondary"
|
||||
data-testid="base-card-back-button"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||
/>
|
||||
Room Name
|
||||
</div>
|
||||
<div
|
||||
class="roundedCornerContainer"
|
||||
>
|
||||
<div
|
||||
style="background-color: grey; flex-grow: 1;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`WidgetPipView > detects double click triggers back 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
data-testid="widget-pip-container"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_c_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="secondary"
|
||||
data-testid="base-card-back-button"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||
/>
|
||||
Room Name
|
||||
</div>
|
||||
<div
|
||||
class="roundedCornerContainer"
|
||||
>
|
||||
<div
|
||||
style="background-color: grey; flex-grow: 1;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`WidgetPipView > detects on mouse down for drag 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
data-testid="widget-pip-container"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_i_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="secondary"
|
||||
data-testid="base-card-back-button"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||
/>
|
||||
Room Name
|
||||
</div>
|
||||
<div
|
||||
class="roundedCornerContainer"
|
||||
>
|
||||
<div
|
||||
style="background-color: grey; flex-grow: 1;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
exports[`WidgetPipView > renders with gray widget 1`] = `
|
||||
<div>
|
||||
<div
|
||||
class="container"
|
||||
data-testid="widget-pip-container"
|
||||
>
|
||||
<div
|
||||
class="header"
|
||||
>
|
||||
<button
|
||||
aria-labelledby="_r_0_"
|
||||
class="_icon-button_1215g_8"
|
||||
data-kind="secondary"
|
||||
data-testid="base-card-back-button"
|
||||
role="button"
|
||||
style="--cpd-icon-button-size: 28px;"
|
||||
tabindex="0"
|
||||
>
|
||||
<div
|
||||
class="_indicator-icon_147l5_17"
|
||||
style="--cpd-icon-button-size: 100%;"
|
||||
>
|
||||
<svg
|
||||
fill="currentColor"
|
||||
height="1em"
|
||||
viewBox="0 0 24 24"
|
||||
width="1em"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
>
|
||||
<path
|
||||
d="m13.3 17.3-4.6-4.6a.9.9 0 0 1-.213-.325A1.1 1.1 0 0 1 8.425 12q0-.2.062-.375A.9.9 0 0 1 8.7 11.3l4.6-4.6a.95.95 0 0 1 .7-.275q.425 0 .7.275a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7L10.8 12l3.9 3.9a.95.95 0 0 1 .275.7.95.95 0 0 1-.275.7.95.95 0 0 1-.7.275.95.95 0 0 1-.7-.275"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
style="width: 20px; height: 20px; border-radius: 50%; background-color: grey;"
|
||||
/>
|
||||
Room Name
|
||||
</div>
|
||||
<div
|
||||
class="roundedCornerContainer"
|
||||
>
|
||||
<div
|
||||
style="background-color: grey; flex-grow: 1;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
8
packages/shared-components/src/room/WidgetPip/index.ts
Normal file
8
packages/shared-components/src/room/WidgetPip/index.ts
Normal file
@ -0,0 +1,8 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
export * from "./WidgetPipView";
|
||||
Loading…
x
Reference in New Issue
Block a user