mirror of
https://github.com/element-hq/element-web.git
synced 2025-12-07 01:21:02 +00:00
Compare commits
11 Commits
dbkr/uploa
...
toger5/dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a00a8d85ac | ||
|
|
2e182580bd | ||
|
|
5b659fe2e5 | ||
|
|
42c718666c | ||
|
|
f3a181a792 | ||
|
|
148d7fc0a9 | ||
|
|
e42fcb797f | ||
|
|
31fb23a170 | ||
|
|
69c2afe8e4 | ||
|
|
bc1effd2a2 | ||
|
|
3b0c04c2e9 |
@@ -21,6 +21,7 @@ jobs:
|
||||
environment: Netlify
|
||||
permissions:
|
||||
actions: read
|
||||
deployments: write
|
||||
steps:
|
||||
- name: Install tree
|
||||
run: "sudo apt-get install -y tree"
|
||||
@@ -34,7 +35,7 @@ jobs:
|
||||
path: received-images
|
||||
|
||||
- name: Generate Index
|
||||
run: "tree -L 1 --noreport -H '-.' -o received-images/index.html received-images"
|
||||
run: "cd received-images && tree -L 1 --noreport -H '' -o index.html ."
|
||||
|
||||
- name: 📤 Deploy to Netlify
|
||||
uses: matrix-org/netlify-pr-preview@9805cd123fc9a7e421e35340a05e1ebc5dee46b5 # v3
|
||||
@@ -46,4 +47,5 @@ jobs:
|
||||
token: ${{ secrets.NETLIFY_AUTH_TOKEN }}
|
||||
site_id: ${{ vars.NETLIFY_SITE_ID }}
|
||||
desc: Shared Component Visual Diffs
|
||||
deployment_env: SharedComponentDiffs
|
||||
prefix: "diffs-"
|
||||
|
||||
@@ -7,7 +7,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
|
||||
import { SynapseContainer as BaseSynapseContainer } from "@element-hq/element-web-playwright-common/lib/testcontainers";
|
||||
|
||||
const TAG = "develop@sha256:a0512b3a44000dd227663c5af1289213d4c78057dcb4fa6e56e8d5e5314af1a2";
|
||||
const TAG = "develop@sha256:b38e55f06543f83f5a13f1d843489eb7aeaf7370a5c17a51897b462eeca315f5";
|
||||
|
||||
/**
|
||||
* SynapseContainer which freezes the docker digest to stabilise tests,
|
||||
|
||||
@@ -245,5 +245,6 @@ Please see LICENSE files in the repository root for full details.
|
||||
/* Thew new rooms list is not designed to be collapsed to just icons. */
|
||||
/* 224 + 68(spaces bar) was deemed by design to be a good minimum for the left panel. */
|
||||
--collapsedWidth: 224px;
|
||||
background-color: var(--cpd-color-bg-canvas-default);
|
||||
/* Important to force the color on ED titlebar until we remove the old room list */
|
||||
background-color: var(--cpd-color-bg-canvas-default) !important;
|
||||
}
|
||||
|
||||
1
src/@types/global.d.ts
vendored
1
src/@types/global.d.ts
vendored
@@ -135,6 +135,7 @@ declare global {
|
||||
initialise(): Promise<{
|
||||
protocol: string;
|
||||
sessionId: string;
|
||||
supportsBadgeOverlay: boolean;
|
||||
config: IConfigOptions;
|
||||
supportedSettings: Record<string, boolean>;
|
||||
}>;
|
||||
|
||||
@@ -494,15 +494,12 @@ export default abstract class BasePlatform {
|
||||
}
|
||||
|
||||
private updateFavicon(): void {
|
||||
let bgColor = "#d00";
|
||||
let notif: string | number = this.notificationCount;
|
||||
const notif: string | number = this.notificationCount;
|
||||
|
||||
if (this.errorDidOccur) {
|
||||
notif = notif || "×";
|
||||
bgColor = "#f00";
|
||||
this.favicon.badge(notif || "×", { bgColor: "#f00" });
|
||||
}
|
||||
|
||||
this.favicon.badge(notif, { bgColor });
|
||||
this.favicon.badge(notif);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -19,6 +19,7 @@ import FilteredList from "./FilteredList";
|
||||
import Spinner from "../../elements/Spinner";
|
||||
import SyntaxHighlight from "../../elements/SyntaxHighlight";
|
||||
import { useAsyncMemo } from "../../../../hooks/useAsyncMemo";
|
||||
import LabelledToggleSwitch from "../../elements/LabelledToggleSwitch";
|
||||
|
||||
export const StateEventEditor: React.FC<IEditorProps> = ({ mxEvent, onBack }) => {
|
||||
const context = useContext(DevtoolsContext);
|
||||
@@ -114,6 +115,7 @@ const RoomStateExplorerEventType: React.FC<IEventTypeProps> = ({ eventType, onBa
|
||||
const [query, setQuery] = useState("");
|
||||
const [event, setEvent] = useState<MatrixEvent | null>(null);
|
||||
const [history, setHistory] = useState(false);
|
||||
const [hideEmptyState, setHideEmptyState] = useState(false);
|
||||
|
||||
const events = context.room.currentState.events.get(eventType)!;
|
||||
|
||||
@@ -149,10 +151,13 @@ const RoomStateExplorerEventType: React.FC<IEventTypeProps> = ({ eventType, onBa
|
||||
return (
|
||||
<BaseTool onBack={onBack}>
|
||||
<FilteredList query={query} onChange={setQuery}>
|
||||
{Array.from(events.entries()).map(([stateKey, ev]) => (
|
||||
<StateEventButton key={stateKey} label={stateKey} onClick={() => setEvent(ev)} />
|
||||
))}
|
||||
{Array.from(events.entries())
|
||||
.filter(([_, ev]) => !hideEmptyState || Object.keys(ev.getContent()).length > 0)
|
||||
.map(([stateKey, ev]) => (
|
||||
<StateEventButton key={stateKey} label={stateKey} onClick={() => setEvent(ev)} />
|
||||
))}
|
||||
</FilteredList>
|
||||
<LabelledToggleSwitch label={_t("devtools|hide_empty_content_events")} onChange={setHideEmptyState} value={hideEmptyState} />
|
||||
</BaseTool>
|
||||
);
|
||||
};
|
||||
|
||||
159
src/favicon.ts
159
src/favicon.ts
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2020-2024 New Vector Ltd.
|
||||
Copyright 2020-2025 New Vector 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.
|
||||
@@ -28,56 +28,19 @@ const defaults: IParams = {
|
||||
isLeft: false,
|
||||
};
|
||||
|
||||
// Allows dynamic rendering of a circular badge atop the loaded favicon
|
||||
// supports colour, font and basic positioning parameters.
|
||||
// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license]
|
||||
export default class Favicon {
|
||||
private readonly browser = {
|
||||
ff: typeof window.InstallTrigger !== "undefined",
|
||||
opera: !!window.opera || navigator.userAgent.includes("Opera"),
|
||||
};
|
||||
|
||||
private readonly params: IParams;
|
||||
private readonly canvas: HTMLCanvasElement;
|
||||
private readonly baseImage: HTMLImageElement;
|
||||
private context!: CanvasRenderingContext2D;
|
||||
private icons: HTMLLinkElement[];
|
||||
|
||||
private isReady = false;
|
||||
// callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown
|
||||
private readyCb?: () => void;
|
||||
|
||||
public constructor(params: Partial<IParams> = {}) {
|
||||
this.params = { ...defaults, ...params };
|
||||
|
||||
this.icons = Favicon.getIcons();
|
||||
// create work canvas
|
||||
abstract class IconRenderer {
|
||||
protected readonly canvas: HTMLCanvasElement;
|
||||
protected readonly context: CanvasRenderingContext2D;
|
||||
public constructor(
|
||||
protected readonly params: IParams = defaults,
|
||||
protected readonly baseImage?: HTMLImageElement,
|
||||
) {
|
||||
this.canvas = document.createElement("canvas");
|
||||
// create clone of favicon as a base
|
||||
this.baseImage = document.createElement("img");
|
||||
|
||||
const lastIcon = this.icons[this.icons.length - 1];
|
||||
if (lastIcon.hasAttribute("href")) {
|
||||
this.baseImage.setAttribute("crossOrigin", "anonymous");
|
||||
this.baseImage.onload = (): void => {
|
||||
// get height and width of the favicon
|
||||
this.canvas.height = this.baseImage.height > 0 ? this.baseImage.height : 32;
|
||||
this.canvas.width = this.baseImage.width > 0 ? this.baseImage.width : 32;
|
||||
this.context = this.canvas.getContext("2d")!;
|
||||
this.ready();
|
||||
};
|
||||
this.baseImage.setAttribute("src", lastIcon.getAttribute("href")!);
|
||||
} else {
|
||||
this.canvas.height = this.baseImage.height = 32;
|
||||
this.canvas.width = this.baseImage.width = 32;
|
||||
this.context = this.canvas.getContext("2d")!;
|
||||
this.ready();
|
||||
const context = this.canvas.getContext("2d");
|
||||
if (!context) {
|
||||
throw Error("Could not get canvas context");
|
||||
}
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context = context;
|
||||
}
|
||||
|
||||
private options(
|
||||
@@ -125,11 +88,23 @@ export default class Favicon {
|
||||
return opt;
|
||||
}
|
||||
|
||||
private circle(n: number | string, opts?: Partial<IParams>): void {
|
||||
/**
|
||||
* Draws a circualr status icon, usually over the top of the application icon.
|
||||
* @param n The content of the circle. Should be a number or a single character.
|
||||
* @param opts Options to adjust.
|
||||
*/
|
||||
protected circle(n: number | string, opts?: Partial<IParams>): void {
|
||||
const params = { ...this.params, ...opts };
|
||||
const opt = this.options(n, params);
|
||||
|
||||
let more = false;
|
||||
if (!this.baseImage) {
|
||||
// If we omit the background, assume the entire canvas is our target.
|
||||
opt.x = 0;
|
||||
opt.y = 0;
|
||||
opt.w = this.canvas.width;
|
||||
opt.h = this.canvas.height;
|
||||
}
|
||||
if (opt.len === 2) {
|
||||
opt.x = opt.x - opt.w * 0.4;
|
||||
opt.w = opt.w * 1.4;
|
||||
@@ -141,7 +116,9 @@ export default class Favicon {
|
||||
}
|
||||
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||
if (this.baseImage) {
|
||||
this.context.drawImage(this.baseImage, 0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
this.context.beginPath();
|
||||
const fontSize = Math.floor(opt.h * (typeof opt.n === "number" && opt.n > 99 ? 0.85 : 1)) + "px";
|
||||
this.context.font = `${params.fontWeight} ${fontSize} ${params.fontFamily}`;
|
||||
@@ -177,6 +154,86 @@ export default class Favicon {
|
||||
|
||||
this.context.closePath();
|
||||
}
|
||||
}
|
||||
|
||||
export class BadgeOverlayRenderer extends IconRenderer {
|
||||
public constructor() {
|
||||
super();
|
||||
// Overlays are 16x16 https://www.electronjs.org/docs/latest/api/browser-window#winsetoverlayiconoverlay-description-windows
|
||||
this.canvas.width = 16;
|
||||
this.canvas.height = 16;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate an overlay badge without the application icon, and export
|
||||
* as an ArrayBuffer
|
||||
* @param contents The content of the circle. Should be a number or a single character.
|
||||
* @param bgColor Optional alternative background colo.r
|
||||
* @returns An ArrayBuffer representing a 16x16 icon in `image/png` format, or `null` if no badge should be drawn.
|
||||
*/
|
||||
public async render(contents: number | string, bgColor?: string): Promise<ArrayBuffer | null> {
|
||||
if (contents === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
this.circle(contents, { ...(bgColor ? { bgColor } : undefined) });
|
||||
return new Promise((resolve, reject) => {
|
||||
this.canvas.toBlob(
|
||||
(blob) => {
|
||||
if (blob) {
|
||||
resolve(blob.arrayBuffer());
|
||||
}
|
||||
reject(new Error("Could not render badge overlay as blob"));
|
||||
},
|
||||
"image/png",
|
||||
1,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// Allows dynamic rendering of a circular badge atop the loaded favicon
|
||||
// supports colour, font and basic positioning parameters.
|
||||
// Based upon https://github.com/ejci/favico.js/blob/master/favico.js [MIT license]
|
||||
export default class Favicon extends IconRenderer {
|
||||
private readonly browser = {
|
||||
ff: typeof window.InstallTrigger !== "undefined",
|
||||
opera: !!window.opera || navigator.userAgent.includes("Opera"),
|
||||
};
|
||||
|
||||
private icons: HTMLLinkElement[];
|
||||
|
||||
private isReady = false;
|
||||
// callback to run once isReady is asserted, allows for a badge to be queued for when it can be shown
|
||||
private readyCb?: () => void;
|
||||
|
||||
public constructor() {
|
||||
const baseImage = document.createElement("img");
|
||||
super(defaults, baseImage);
|
||||
|
||||
this.icons = Favicon.getIcons();
|
||||
|
||||
const lastIcon = this.icons[this.icons.length - 1];
|
||||
if (lastIcon.hasAttribute("href")) {
|
||||
baseImage.setAttribute("crossOrigin", "anonymous");
|
||||
baseImage.onload = (): void => {
|
||||
// get height and width of the favicon
|
||||
this.canvas.height = baseImage.height > 0 ? baseImage.height : 32;
|
||||
this.canvas.width = baseImage.width > 0 ? baseImage.width : 32;
|
||||
this.ready();
|
||||
};
|
||||
baseImage.setAttribute("src", lastIcon.getAttribute("href")!);
|
||||
} else {
|
||||
this.canvas.height = baseImage.height = 32;
|
||||
this.canvas.width = baseImage.width = 32;
|
||||
this.ready();
|
||||
}
|
||||
}
|
||||
|
||||
private reset(): void {
|
||||
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height);
|
||||
this.context.drawImage(this.baseImage!, 0, 0, this.canvas.width, this.canvas.height);
|
||||
}
|
||||
|
||||
private ready(): void {
|
||||
if (this.isReady) return;
|
||||
|
||||
@@ -819,6 +819,7 @@
|
||||
"failed_to_load": "Failed to load.",
|
||||
"failed_to_save": "Failed to save settings.",
|
||||
"failed_to_send": "Failed to send event!",
|
||||
"hide_empty_content_events": "Hide events with empty content",
|
||||
"id": "ID: ",
|
||||
"invalid_json": "Doesn't look like valid JSON.",
|
||||
"level": "Level",
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024-2025 New Vector Ltd.
|
||||
Copyright 2022 Šimon Brandner <simon.bra.ag@gmail.com>
|
||||
Copyright 2018-2021 New Vector Ltd
|
||||
Copyright 2019 Michael Telatynski <7t3chguy@gmail.com>
|
||||
@@ -42,6 +42,7 @@ import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { SeshatIndexManager } from "./SeshatIndexManager";
|
||||
import { IPCManager } from "./IPCManager";
|
||||
import { _t } from "../../languageHandler";
|
||||
import { BadgeOverlayRenderer } from "../../favicon";
|
||||
|
||||
interface SquirrelUpdate {
|
||||
releaseNotes: string;
|
||||
@@ -87,10 +88,11 @@ function getUpdateCheckStatus(status: boolean | string): UpdateStatus {
|
||||
export default class ElectronPlatform extends BasePlatform {
|
||||
private readonly ipc = new IPCManager("ipcCall", "ipcReply");
|
||||
private readonly eventIndexManager: BaseEventIndexManager = new SeshatIndexManager();
|
||||
private readonly initialised: Promise<void>;
|
||||
public readonly initialised: Promise<void>;
|
||||
private readonly electron: Electron;
|
||||
private protocol!: string;
|
||||
private sessionId!: string;
|
||||
private badgeOverlayRenderer?: BadgeOverlayRenderer;
|
||||
private config!: IConfigOptions;
|
||||
private supportedSettings?: Record<string, boolean>;
|
||||
|
||||
@@ -194,11 +196,15 @@ export default class ElectronPlatform extends BasePlatform {
|
||||
}
|
||||
|
||||
private async initialise(): Promise<void> {
|
||||
const { protocol, sessionId, config, supportedSettings } = await this.electron.initialise();
|
||||
const { protocol, sessionId, config, supportedSettings, supportsBadgeOverlay } =
|
||||
await this.electron.initialise();
|
||||
this.protocol = protocol;
|
||||
this.sessionId = sessionId;
|
||||
this.config = config;
|
||||
this.supportedSettings = supportedSettings;
|
||||
if (supportsBadgeOverlay) {
|
||||
this.badgeOverlayRenderer = new BadgeOverlayRenderer();
|
||||
}
|
||||
}
|
||||
|
||||
public async getConfig(): Promise<IConfigOptions | undefined> {
|
||||
@@ -249,8 +255,42 @@ export default class ElectronPlatform extends BasePlatform {
|
||||
public setNotificationCount(count: number): void {
|
||||
if (this.notificationCount === count) return;
|
||||
super.setNotificationCount(count);
|
||||
if (this.badgeOverlayRenderer) {
|
||||
this.badgeOverlayRenderer
|
||||
.render(count)
|
||||
.then((buffer) => {
|
||||
this.electron.send("setBadgeCount", count, buffer);
|
||||
})
|
||||
.catch((ex) => {
|
||||
logger.warn("Unable to generate badge overlay", ex);
|
||||
});
|
||||
} else {
|
||||
this.electron.send("setBadgeCount", count);
|
||||
}
|
||||
}
|
||||
|
||||
this.electron.send("setBadgeCount", count);
|
||||
public setErrorStatus(errorDidOccur: boolean): void {
|
||||
if (!this.badgeOverlayRenderer) {
|
||||
super.setErrorStatus(errorDidOccur);
|
||||
return;
|
||||
}
|
||||
// Check before calling super so we don't override the previous state.
|
||||
if (this.errorDidOccur !== errorDidOccur) {
|
||||
super.setErrorStatus(errorDidOccur);
|
||||
let promise: Promise<ArrayBuffer | null>;
|
||||
if (errorDidOccur) {
|
||||
promise = this.badgeOverlayRenderer.render(this.notificationCount || "×", "#f00");
|
||||
} else {
|
||||
promise = this.badgeOverlayRenderer.render(this.notificationCount);
|
||||
}
|
||||
promise
|
||||
.then((buffer) => {
|
||||
this.electron.send("setBadgeCount", this.notificationCount, buffer, errorDidOccur);
|
||||
})
|
||||
.catch((ex) => {
|
||||
logger.warn("Unable to generate badge overlay", ex);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
public supportsNotifications(): boolean {
|
||||
|
||||
56
src/viewmodels/SubscriptionViewModel.ts
Normal file
56
src/viewmodels/SubscriptionViewModel.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 ViewModel } from "../shared-components/ViewModel";
|
||||
import { ViewModelSubscriptions } from "./ViewModelSubscriptions";
|
||||
|
||||
export abstract class SubscriptionViewModel<T> implements ViewModel<T> {
|
||||
protected subs: ViewModelSubscriptions;
|
||||
|
||||
protected constructor() {
|
||||
this.subs = new ViewModelSubscriptions(
|
||||
this.addDownstreamSubscriptionWrapper,
|
||||
this.removeDownstreamSubscriptionWrapper,
|
||||
);
|
||||
}
|
||||
|
||||
public subscribe = (listener: () => void): (() => void) => {
|
||||
return this.subs.add(listener);
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper around the abstract subscribe callback as we can't assume that the subclassed method
|
||||
* has a bound `this` context.
|
||||
*/
|
||||
private addDownstreamSubscriptionWrapper = (): void => {
|
||||
this.addDownstreamSubscription();
|
||||
};
|
||||
|
||||
/**
|
||||
* Wrapper around the abstract unsubscribe callback as we can't call pass an abstract method directly
|
||||
* in the constructor.
|
||||
*/
|
||||
private removeDownstreamSubscriptionWrapper = (): void => {
|
||||
this.removeDownstreamSubscription();
|
||||
};
|
||||
|
||||
/**
|
||||
* Called when the first listener subscribes: the subclass should set up any necessary subscriptions
|
||||
* to call this.subs.emit() when the snapshot changes.
|
||||
*/
|
||||
protected abstract addDownstreamSubscription(): void;
|
||||
|
||||
/**
|
||||
* Called when the last listener unsubscribes: the subclass should clean up any subscriptions.
|
||||
*/
|
||||
protected abstract removeDownstreamSubscription(): void;
|
||||
|
||||
/**
|
||||
* Returns the current snapshot of the view model.
|
||||
*/
|
||||
public abstract getSnapshot: () => T;
|
||||
}
|
||||
@@ -12,7 +12,8 @@ export class ViewModelSubscriptions {
|
||||
private listeners = new Set<() => void>();
|
||||
|
||||
/**
|
||||
* @param updateSubscription A function called whenever a listener is added or removed.
|
||||
* @param subscribeCallback Called when the first listener subscribes.
|
||||
* @param unsubscribeCallback Called when the last listener unsubscribes.
|
||||
*/
|
||||
public constructor(
|
||||
private subscribeCallback: () => void,
|
||||
@@ -41,17 +42,9 @@ export class ViewModelSubscriptions {
|
||||
/**
|
||||
* Emit an update to all subscribed listeners.
|
||||
*/
|
||||
public emit(): void {
|
||||
public emit = (): void => {
|
||||
for (const listener of this.listeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the number of listeners currently subscribed to updates.
|
||||
* @returns The number of listeners.
|
||||
*/
|
||||
public listenerCount(): number {
|
||||
return this.listeners.size;
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -10,27 +10,20 @@ import { MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||
import { type EventTileTypeProps } from "../../events/EventTileFactory";
|
||||
import { MatrixClientPeg } from "../../MatrixClientPeg";
|
||||
import { textForEvent } from "../../TextForEvent";
|
||||
import { ViewModelSubscriptions } from "../ViewModelSubscriptions";
|
||||
import { type TextualEventViewSnapshot } from "../../shared-components/event-tiles/TextualEvent/TextualEvent";
|
||||
import { type ViewModel } from "../../shared-components/ViewModel";
|
||||
|
||||
export class TextualEventViewModel implements ViewModel<TextualEventViewSnapshot> {
|
||||
private subs: ViewModelSubscriptions;
|
||||
import { SubscriptionViewModel } from "../SubscriptionViewModel";
|
||||
|
||||
export class TextualEventViewModel extends SubscriptionViewModel<TextualEventViewSnapshot> {
|
||||
public constructor(private eventTileProps: EventTileTypeProps) {
|
||||
this.subs = new ViewModelSubscriptions(this.addSubscription, this.removeSubscription);
|
||||
super();
|
||||
}
|
||||
|
||||
private addSubscription = (): void => {
|
||||
this.eventTileProps.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
|
||||
protected addDownstreamSubscription = (): void => {
|
||||
this.eventTileProps.mxEvent.on(MatrixEventEvent.SentinelUpdated, this.subs.emit);
|
||||
};
|
||||
|
||||
private removeSubscription = (): void => {
|
||||
this.eventTileProps.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.onEventSentinelUpdated);
|
||||
};
|
||||
|
||||
public subscribe = (listener: () => void): (() => void) => {
|
||||
return this.subs.add(listener);
|
||||
protected removeDownstreamSubscription = (): void => {
|
||||
this.eventTileProps.mxEvent.off(MatrixEventEvent.SentinelUpdated, this.subs.emit);
|
||||
};
|
||||
|
||||
public getSnapshot = (): TextualEventViewSnapshot => {
|
||||
@@ -42,8 +35,4 @@ export class TextualEventViewModel implements ViewModel<TextualEventViewSnapshot
|
||||
);
|
||||
return text;
|
||||
};
|
||||
|
||||
private onEventSentinelUpdated = (): void => {
|
||||
this.subs.emit();
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1603,7 +1603,8 @@ describe("<MatrixChat />", () => {
|
||||
Lifecycle.setSessionLockNotStolen();
|
||||
});
|
||||
|
||||
it("waits for other tab to stop during startup", async () => {
|
||||
// Flaky test, see https://github.com/element-hq/element-web/issues/30337
|
||||
it.skip("waits for other tab to stop during startup", async () => {
|
||||
fetchMock.get("/welcome.html", { body: "<h1>Hello</h1>" });
|
||||
jest.spyOn(Lifecycle, "attemptDelegatedAuthLogin");
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
Copyright 2024 New Vector Ltd.
|
||||
Copyright 2024-2025 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
|
||||
@@ -9,6 +9,7 @@ Please see LICENSE files in the repository root for full details.
|
||||
import { logger } from "matrix-js-sdk/src/logger";
|
||||
import { MatrixEvent, Room } from "matrix-js-sdk/src/matrix";
|
||||
import { mocked, type MockedObject } from "jest-mock";
|
||||
import { waitFor } from "jest-matrix-react";
|
||||
|
||||
import { UpdateCheckStatus } from "../../../../src/BasePlatform";
|
||||
import { Action } from "../../../../src/dispatcher/actions";
|
||||
@@ -26,18 +27,20 @@ jest.mock("../../../../src/rageshake/rageshake", () => ({
|
||||
}));
|
||||
|
||||
describe("ElectronPlatform", () => {
|
||||
const initialiseValues = jest.fn().mockReturnValue({
|
||||
protocol: "io.element.desktop",
|
||||
sessionId: "session-id",
|
||||
config: { _config: true },
|
||||
supportedSettings: { setting1: false, setting2: true },
|
||||
supportsBadgeOverlay: false,
|
||||
});
|
||||
const defaultUserAgent =
|
||||
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
||||
"(KHTML, like Gecko) Chrome/105.0.0.0 Safari/537.36";
|
||||
const mockElectron = {
|
||||
on: jest.fn(),
|
||||
send: jest.fn(),
|
||||
initialise: jest.fn().mockResolvedValue({
|
||||
protocol: "io.element.desktop",
|
||||
sessionId: "session-id",
|
||||
config: { _config: true },
|
||||
supportedSettings: { setting1: false, setting2: true },
|
||||
}),
|
||||
initialise: initialiseValues,
|
||||
setSettingValue: jest.fn().mockResolvedValue(undefined),
|
||||
getSettingValue: jest.fn().mockResolvedValue(undefined),
|
||||
} as unknown as MockedObject<Electron>;
|
||||
@@ -405,4 +408,101 @@ describe("ElectronPlatform", () => {
|
||||
state: "connected",
|
||||
});
|
||||
});
|
||||
|
||||
describe("Notification overlay badges", () => {
|
||||
beforeEach(() => {
|
||||
initialiseValues.mockReturnValue({
|
||||
protocol: "io.element.desktop",
|
||||
sessionId: "session-id",
|
||||
config: { _config: true },
|
||||
supportsBadgeOverlay: true,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("should send a badge with a notification count", async () => {
|
||||
const platform = new ElectronPlatform();
|
||||
await platform.initialised;
|
||||
platform.setNotificationCount(1);
|
||||
// Badges are sent asynchronously
|
||||
await waitFor(() => {
|
||||
const ipcMessage = mockElectron.send.mock.lastCall;
|
||||
expect(ipcMessage?.[1]).toEqual(1);
|
||||
expect(ipcMessage?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
});
|
||||
});
|
||||
|
||||
it("should update badge and skip duplicates", async () => {
|
||||
const platform = new ElectronPlatform();
|
||||
await platform.initialised;
|
||||
platform.setNotificationCount(1);
|
||||
platform.setNotificationCount(1); // Test that duplicates do not fire.
|
||||
platform.setNotificationCount(2);
|
||||
// Badges are sent asynchronously
|
||||
await waitFor(() => {
|
||||
const [ipcMessageA, ipcMessageB] = mockElectron.send.mock.calls.filter(
|
||||
(call) => call[0] === "setBadgeCount",
|
||||
);
|
||||
|
||||
expect(ipcMessageA?.[1]).toEqual(1);
|
||||
expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
|
||||
expect(ipcMessageB?.[1]).toEqual(2);
|
||||
expect(ipcMessageB?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
});
|
||||
});
|
||||
it("should remove badge when notification count zeros", async () => {
|
||||
const platform = new ElectronPlatform();
|
||||
await platform.initialised;
|
||||
platform.setNotificationCount(1);
|
||||
platform.setNotificationCount(0); // Test that duplicates do not fire.
|
||||
// Badges are sent asynchronously
|
||||
await waitFor(() => {
|
||||
const [ipcMessageB, ipcMessageA] = mockElectron.send.mock.calls.filter(
|
||||
(call) => call[0] === "setBadgeCount",
|
||||
);
|
||||
|
||||
expect(ipcMessageA?.[1]).toEqual(1);
|
||||
expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
|
||||
expect(ipcMessageB?.[1]).toEqual(0);
|
||||
expect(ipcMessageB?.[2]).toBeNull();
|
||||
});
|
||||
});
|
||||
it("should show an error badge when the application errors", async () => {
|
||||
const platform = new ElectronPlatform();
|
||||
await platform.initialised;
|
||||
platform.setErrorStatus(true);
|
||||
// Badges are sent asynchronously
|
||||
await waitFor(() => {
|
||||
const ipcMessage = mockElectron.send.mock.calls.find((call) => call[0] === "setBadgeCount");
|
||||
|
||||
expect(ipcMessage?.[1]).toEqual(0);
|
||||
expect(ipcMessage?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
expect(ipcMessage?.[3]).toEqual(true);
|
||||
});
|
||||
});
|
||||
it("should restore after error is resolved", async () => {
|
||||
const platform = new ElectronPlatform();
|
||||
await platform.initialised;
|
||||
platform.setErrorStatus(true);
|
||||
platform.setErrorStatus(false);
|
||||
// Badges are sent asynchronously
|
||||
await waitFor(() => {
|
||||
const [ipcMessageB, ipcMessageA] = mockElectron.send.mock.calls.filter(
|
||||
(call) => call[0] === "setBadgeCount",
|
||||
);
|
||||
|
||||
expect(ipcMessageA?.[1]).toEqual(0);
|
||||
expect(ipcMessageA?.[2] instanceof ArrayBuffer).toEqual(true);
|
||||
expect(ipcMessageA?.[3]).toEqual(true);
|
||||
|
||||
expect(ipcMessageB?.[1]).toEqual(0);
|
||||
expect(ipcMessageB?.[2]).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
29
test/viewmodels/event-tiles/TextualEventViewModel-test.ts
Normal file
29
test/viewmodels/event-tiles/TextualEventViewModel-test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
Copyright 2025 New Vector 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 { MatrixEvent, MatrixEventEvent } from "matrix-js-sdk/src/matrix";
|
||||
|
||||
import { TextualEventViewModel } from "../../../src/viewmodels/event-tiles/TextualEventViewModel";
|
||||
|
||||
describe("TextualEventViewModel", () => {
|
||||
it("should update when the sentinel updates", () => {
|
||||
const fakeEvent = new MatrixEvent({});
|
||||
|
||||
const vm = new TextualEventViewModel({
|
||||
showHiddenEvents: false,
|
||||
mxEvent: fakeEvent,
|
||||
});
|
||||
|
||||
const cb = jest.fn();
|
||||
|
||||
vm.subscribe(cb);
|
||||
|
||||
fakeEvent.emit(MatrixEventEvent.SentinelUpdated);
|
||||
|
||||
expect(cb).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user