Blap reskin: social UI, fixes, and update/release pipeline

Fork of element-web v1.12.22 reskinned as "Blap", a Discord-style social
Matrix client. Visual fork only: calls stay on the Element Call widget.

UI / reskin:
- yap CSS overlay (_yap.pcss) wired into the dark theme @layer; fonts, surfaces
- spaces rail icon-only with the collapse mechanism removed
- YapRoomList with manual drag-and-drop ordering (works in Home; drop-to-bottom)
- Discord-style inline message timestamps next to the sender name
- image lightbox toolbar visibility; themed context menus + toasts
- bridges panel + call facepile
- login screen rebuilt to the Blap design (centered card, accent buttons)
- accent (purple) primary buttons app-wide via Compound token overrides

Desktop / distribution:
- updater.ts switched Squirrel -> electron-updater (NSIS); Codeberg generic feed
- scripts/release-codeberg.mjs uploads artifacts to the Codeberg release
- Blap build variant (apps/desktop/blap/build.json, appId lol.utn.blap)

Calls (visual-only via widget API):
- "join with camera off" device setting + Voice settings toggle

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-29 21:37:49 -07:00
parent 94636e8d4d
commit 5b9d457704
49 changed files with 2662 additions and 568 deletions
+7
View File
@@ -0,0 +1,7 @@
{
"appId": "lol.utn.blap",
"name": "blap",
"productName": "Blap",
"description": "Blap: a social messaging client",
"protocols": ["blap"]
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 34 KiB

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 35 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+37 -2
View File
@@ -128,8 +128,18 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
electron_appId: variant.appId,
electron_protocol: variant.protocols[0],
},
// Codeberg release feed for electron-updater. electron-builder bakes this into
// app-update.yml and generates the channel files (latest.yml / latest-linux.yml).
// "generic" = download-only (upload is handled by scripts/release-codeberg.mjs),
// pointed at Forgejo's stable "latest release" download path.
publish: {
provider: "generic",
url: "https://codeberg.org/Enkisu/Blap/releases/latest/download/",
channel: "latest",
},
linux: {
target: ["tar.gz", "deb"],
// AppImage is the only Linux format electron-updater can auto-update in-app.
target: ["tar.gz", "deb", "AppImage"],
category: "Network;InstantMessaging;Chat",
icon: "icon.png",
executableName: variant.name, // element-desktop or element-desktop-nightly
@@ -152,6 +162,24 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
recommends: ["libsqlcipher0", "element-io-archive-keyring"],
fpm: ["--deb-pre-depends", "libc6 (>= 2.31)"],
},
flatpak: {
// Freedesktop runtime + Electron base app installed locally at this version.
runtimeVersion: "24.08",
baseVersion: "24.08",
finishArgs: [
"--share=ipc",
"--share=network",
"--socket=x11",
"--socket=wayland",
"--socket=pulseaudio",
"--device=dri",
"--device=all", // camera for video calls
"--talk-name=org.freedesktop.Notifications",
"--talk-name=org.kde.StatusNotifierWatcher",
"--talk-name=org.freedesktop.secrets", // libsecret/keyring
"--filesystem=xdg-download",
],
},
mac: {
target: ["dmg", "zip"],
category: "public.app-category.social-networking",
@@ -168,12 +196,19 @@ const config: Omit<Writable<Configuration>, "electronFuses"> & {
badgeIcon: "build/icon.icon",
},
win: {
target: ["squirrel", "msi"],
// NSIS is required for electron-updater auto-update on Windows (replaces
// the old Squirrel target). msi kept for manual/enterprise installs.
target: ["nsis", "msi"],
signtoolOptions: {
signingHashAlgorithms: ["sha256"],
},
icon: "build/icon.ico",
},
nsis: {
oneClick: false,
perMachine: false,
allowToChangeInstallationDirectory: true,
},
msi: {
perMachine: true,
},
+1
View File
@@ -63,6 +63,7 @@
"auto-launch": "^5.0.5",
"counterpart": "^0.18.6",
"electron-store": "^11.0.0",
"electron-updater": "^6.6.2",
"electron-window-state": "^5.0.3",
"minimist": "^1.2.6",
"png-to-ico": "^3.0.0",
+94
View File
@@ -0,0 +1,94 @@
#!/usr/bin/env node
/*
* Upload built desktop artifacts to a Codeberg (Forgejo) release so electron-updater
* (generic provider -> .../releases/latest/download/) can find them.
*
* Run AFTER `pnpm run build` from apps/desktop:
* CODEBERG_TOKEN=xxxx node scripts/release-codeberg.mjs [tag]
*
* - owner/repo are fixed to Enkisu/Blap (must match the electron-builder publish url).
* - tag defaults to "v<version>" from package.json.
* - Uploads latest.yml / latest-linux.yml + installers (.exe/.AppImage) + blockmaps.
* - Replaces assets of the same name so re-runs are idempotent.
* - The release is created non-draft / non-prerelease so Forgejo's "latest" alias
* resolves to it (that's what the updater feed URL points at).
*/
import { readFile, readdir } from "node:fs/promises";
import { basename, join } from "node:path";
import { fileURLToPath } from "node:url";
const OWNER = "Enkisu";
const REPO = "Blap";
const API = "https://codeberg.org/api/v1";
const token = process.env.CODEBERG_TOKEN;
if (!token) {
console.error("CODEBERG_TOKEN env var is required (a Codeberg access token with repo scope).");
process.exit(1);
}
const here = fileURLToPath(new URL(".", import.meta.url));
const pkg = JSON.parse(await readFile(join(here, "..", "package.json"), "utf8"));
const tag = process.argv[2] ?? `v${pkg.version}`;
const distDir = join(here, "..", "dist");
const authHeaders = { Authorization: `token ${token}` };
// Channel files electron-updater needs + the installers themselves.
const WANTED = [/^latest\.yml$/, /^latest-linux\.yml$/, /\.exe$/, /\.exe\.blockmap$/, /\.AppImage$/, /\.AppImage\.blockmap$/];
async function api(path, init = {}) {
const res = await fetch(`${API}${path}`, { ...init, headers: { ...authHeaders, ...(init.headers ?? {}) } });
if (!res.ok && res.status !== 404) {
throw new Error(`${init.method ?? "GET"} ${path} -> ${res.status} ${await res.text()}`);
}
return res;
}
async function getOrCreateRelease() {
let res = await api(`/repos/${OWNER}/${REPO}/releases/tags/${encodeURIComponent(tag)}`);
if (res.status === 404) {
res = await api(`/repos/${OWNER}/${REPO}/releases`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ tag_name: tag, name: tag, draft: false, prerelease: false }),
});
console.log(`Created release ${tag}`);
} else {
console.log(`Reusing existing release ${tag}`);
}
return res.json();
}
async function main() {
const files = (await readdir(distDir)).filter((f) => WANTED.some((re) => re.test(f)));
if (files.length === 0) {
console.error(`No matching artifacts in ${distDir}. Run \`pnpm run build\` first.`);
process.exit(1);
}
const release = await getOrCreateRelease();
const existing = new Map((release.assets ?? []).map((a) => [a.name, a.id]));
for (const file of files) {
const name = basename(file);
if (existing.has(name)) {
await api(`/repos/${OWNER}/${REPO}/releases/${release.id}/assets/${existing.get(name)}`, {
method: "DELETE",
});
}
const data = await readFile(join(distDir, file));
const form = new FormData();
form.append("attachment", new Blob([data]), name);
const res = await fetch(
`${API}/repos/${OWNER}/${REPO}/releases/${release.id}/assets?name=${encodeURIComponent(name)}`,
{ method: "POST", headers: authHeaders, body: form },
);
if (!res.ok) throw new Error(`upload ${name} -> ${res.status} ${await res.text()}`);
console.log(`Uploaded ${name}`);
}
console.log(`\nDone. Updater feed: https://codeberg.org/${OWNER}/${REPO}/releases/latest/download/latest.yml`);
}
await main();
+3 -3
View File
@@ -376,10 +376,10 @@ app.on("ready", async () => {
// Minimist parses `--no-`-prefixed arguments as booleans with value `false` rather than verbatim.
if (argv["update"] === false) {
console.log("Auto update disabled via command line flag");
} else if (global.vectorConfig["update_base_url"]) {
void updater.start(global.vectorConfig["update_base_url"]);
} else {
console.log("No update_base_url is defined: auto update is disabled");
// electron-updater reads its feed from app-update.yml (baked in from the
// electron-builder `publish` config), so no update_base_url is needed.
void updater.start();
}
// Set up i18n before loading storage as we need translations for dialogs
+4 -2
View File
@@ -5,7 +5,7 @@ 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 { app, autoUpdater, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron";
import { app, desktopCapturer, ipcMain, powerSaveBlocker, TouchBar, nativeImage } from "electron";
import IpcMainEvent = Electron.IpcMainEvent;
import { randomArray } from "./utils.js";
@@ -54,7 +54,9 @@ ipcMain.on("ipcCall", async function (_ev: IpcMainEvent, payload) {
switch (payload.name) {
case "getUpdateFeedUrl":
ret = autoUpdater.getFeedURL();
// electron-updater has no runtime feed URL; report a truthy value when
// the app is packaged (and thus updatable) so canSelfUpdate() is true.
ret = app.isPackaged ? "electron-updater" : "";
break;
case "setLanguage":
global.appLocalization.setAppLocale(args[0]);
+54 -173
View File
@@ -5,18 +5,19 @@ 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 { app, autoUpdater, ipcMain } from "electron";
import fs from "node:fs/promises";
import os from "node:os";
import { app, ipcMain } from "electron";
// electron-updater is CommonJS; this is the documented ESM interop form.
import electronUpdater, { type UpdateInfo } from "electron-updater";
import { getSquirrelExecutable } from "./squirrelhooks.js";
import { _t } from "./language-helper.js";
import { initialisePromise } from "./ipc.js";
import { getBrand } from "./config.js";
const { autoUpdater } = electronUpdater;
const UPDATE_POLL_INTERVAL_MS = 60 * 60 * 1000;
const INITIAL_UPDATE_DELAY_MS = 30 * 1000;
function ipcChannelSendUpdateStatus(status: boolean | string): void {
global.mainWindow?.webContents.send("check_updates", status);
}
function installUpdate(): void {
// for some reason, quitAndInstall does not fire the
// before-quit event, so we need to set the flag here.
@@ -24,188 +25,68 @@ function installUpdate(): void {
autoUpdater.quitAndInstall();
}
// Workaround for Squirrel.Mac wedging auto-restart if latest check for update failed
// From https://github.com/vector-im/element-web/issues/12433#issuecomment-1508995119
async function safeCheckForUpdate(): Promise<void> {
if (process.platform === "darwin") {
const feedUrl = autoUpdater.getFeedURL();
// On Mac if the user has already downloaded an update but not installed it and
// we check again and no additional new update is available the app ends up in a
// bad state and doesn't restart after installing any updates that are downloaded.
// To avoid this we check manually whether an update is available and call the
// autoUpdater.checkForUpdates() when something new is there.
try {
const res = await fetch(feedUrl);
const { currentRelease } = (await res.json()) as { currentRelease: string };
const latestVersionDownloaded = latestUpdateDownloaded?.releaseName;
console.info(
`Latest version from release download: ${currentRelease} (current: ${app.getVersion()}, most recent downloaded ${latestVersionDownloaded}})`,
);
if (currentRelease === app.getVersion() || currentRelease === latestVersionDownloaded) {
ipcChannelSendUpdateStatus(false);
return;
}
} catch (err) {
console.error(`Error checking for updates ${feedUrl}`, err);
ipcChannelSendUpdateStatus(false);
return;
}
}
autoUpdater.checkForUpdates();
}
async function pollForUpdates(): Promise<void> {
try {
// If we've already got a new update downloaded, then stop trying to check for new ones, as according to the doc
// at https://github.com/electron/electron/blob/main/docs/api/auto-updater.md#autoupdatercheckforupdates
// we'll just keep re-downloading the same update.
// As a hunch, this might also be causing https://github.com/vector-im/element-web/issues/12433
// due to the update checks colliding with the pending install somehow
if (!latestUpdateDownloaded) {
await safeCheckForUpdate();
} else {
console.log("Skipping update check as download already present");
global.mainWindow?.webContents.send("update-downloaded", latestUpdateDownloaded);
}
await autoUpdater.checkForUpdates();
} catch (e) {
console.log("Couldn't check for update", e);
}
}
export async function start(updateBaseUrl: string): Promise<void> {
if (!(await available())) return;
console.log(`Starting auto update with base URL: ${updateBaseUrl}`);
if (!updateBaseUrl.endsWith("/")) {
updateBaseUrl = updateBaseUrl + "/";
}
try {
let url: string;
let serverType: "json" | undefined;
if (process.platform === "darwin") {
// On macOS it takes a JSON file with a map between versions and their URLs
url = `${updateBaseUrl}macos/releases.json`;
serverType = "json";
} else if (process.platform === "win32") {
// On windows it takes a base path and looks for files under that path.
url = `${updateBaseUrl}win32/${process.arch}/`;
} else {
// Squirrel / electron only supports auto-update on these two platforms.
// I'm not even going to try to guess which feed style they'd use if they
// implemented it on Linux, or if it would be different again.
return;
}
if (url) {
console.log(`Update URL: ${url}`);
autoUpdater.setFeedURL({ url, serverType });
// We check for updates ourselves rather than using 'updater' because we need to
// do it in the main process (and we don't really need to check every 10 minutes:
// every hour should be just fine for a desktop app)
// However, we still let the main window listen for the update events.
// We also wait a short time before checking for updates the first time because
// of squirrel on windows and it taking a small amount of time to release a
// lock file.
setTimeout(pollForUpdates, INITIAL_UPDATE_DELAY_MS);
setInterval(pollForUpdates, UPDATE_POLL_INTERVAL_MS);
}
} catch (err) {
// will fail if running in debug mode
console.log("Couldn't enable update checking", err);
ipcChannelSendUpdateStatus(false);
}
}
/**
* Check if auto update is available on this platform.
* Has a side effect of firing showToast on EOL platforms so must only be called once!
* @returns True if auto update is available
* Start the auto-updater.
*
* Uses electron-updater, which reads its feed from `app-update.yml` baked in at
* package time from the electron-builder `publish` config (a Codeberg release
* "latest/download" URL). No runtime feed URL is needed.
*
* Notes:
* - Only works for packaged builds (dev has no app-update.yml).
* - On Linux, electron-updater only supports AppImage; deb/flatpak update via
* their own channels, so this no-ops there.
* - macOS would need a signed build + zip target to update.
*/
async function available(): Promise<boolean> {
if (process.platform === "linux") {
// Auto update is not supported on Linux
console.warn("Auto update not supported on this platform");
return false;
export async function start(): Promise<void> {
if (!app.isPackaged) {
console.log("Auto update disabled: app is not packaged");
return;
}
if (process.platform === "win32") {
try {
await fs.access(getSquirrelExecutable());
} catch {
console.warn("Squirrel not found, auto update not supported");
return false;
}
}
console.log("Starting auto update via electron-updater");
// Otherwise we're either on macOS or Windows with Squirrel
if (process.platform === "darwin") {
// OS release returns the Darwin kernel version, not the macOS version, see
// https://en.wikipedia.org/wiki/Darwin_(operating_system)#Release_history to interpret it
const release = os.release();
const major = parseInt(release.split(".")[0], 10);
autoUpdater.autoDownload = true;
autoUpdater.autoInstallOnAppQuit = true;
// We do our own hourly polling; don't let electron-updater log noisily.
autoUpdater.logger = null;
if (major < 21) {
// If the macOS version is too old for modern Electron support then disable auto update to prevent the app updating and bricking itself.
// The oldest macOS version supported by Chromium/Electron 38 is Monterey (12.x) which started with Darwin 21.0
initialisePromise.then(() => {
ipcMain.emit("showToast", {
title: _t("eol|title"),
description: _t("eol|no_more_updates", { brand: getBrand() }),
});
autoUpdater
.on("update-available", () => {
// an update is available and (with autoDownload) is being fetched
ipcChannelSendUpdateStatus(true);
})
.on("update-not-available", () => {
ipcChannelSendUpdateStatus(false);
})
.on("error", (error: Error) => {
ipcChannelSendUpdateStatus(error.message);
})
.on("update-downloaded", (info: UpdateInfo) => {
// forward to renderer in the same shape the Squirrel path used, so
// ElectronPlatform's existing "New update" toast keeps working.
global.mainWindow?.webContents.send("update-downloaded", {
releaseNotes: typeof info.releaseNotes === "string" ? info.releaseNotes : "",
releaseName: info.version,
releaseDate: info.releaseDate,
updateURL: "",
});
console.warn("Auto update not supported, macOS version too old");
return false;
} else if (major < 22) {
// If the macOS version is EOL then show a warning message.
// The oldest macOS version still supported by Apple is Ventura (13.x) which started with Darwin 22.0
initialisePromise.then(() => {
ipcMain.emit("showToast", {
title: _t("eol|title"),
description: _t("eol|warning", { brand: getBrand() }),
});
});
}
}
});
return true;
// We check on a slow cadence (every hour is plenty for a desktop app), after
// a short initial delay to let the app settle.
setTimeout(pollForUpdates, INITIAL_UPDATE_DELAY_MS);
setInterval(pollForUpdates, UPDATE_POLL_INTERVAL_MS);
}
ipcMain.on("install_update", installUpdate);
ipcMain.on("check_updates", pollForUpdates);
function ipcChannelSendUpdateStatus(status: boolean | string): void {
global.mainWindow?.webContents.send("check_updates", status);
}
interface ICachedUpdate {
releaseNotes: string;
releaseName: string;
releaseDate: Date;
updateURL: string;
}
// cache the latest update which has been downloaded as electron offers no api to read it
let latestUpdateDownloaded: ICachedUpdate | undefined;
autoUpdater
.on("update-available", function () {
ipcChannelSendUpdateStatus(true);
})
.on("update-not-available", function () {
if (latestUpdateDownloaded) {
// the only time we will get `update-not-available` if `latestUpdateDownloaded` is already set
// is if the user used the Manual Update check and there is no update newer than the one we
// have downloaded, so show it to them as the latest again.
global.mainWindow?.webContents.send("update-downloaded", latestUpdateDownloaded);
} else {
ipcChannelSendUpdateStatus(false);
}
})
.on("error", function (error) {
ipcChannelSendUpdateStatus(error.message);
});
autoUpdater.on("update-downloaded", (ev, releaseNotes, releaseName, releaseDate, updateURL) => {
// forward to renderer
latestUpdateDownloaded = { releaseNotes, releaseName, releaseDate, updateURL };
global.mainWindow?.webContents.send("update-downloaded", latestUpdateDownloaded);
});
+2
View File
@@ -43,7 +43,9 @@
"@element-hq/element-web-module-api": "workspace:*",
"@element-hq/web-shared-components": "workspace:*",
"@fontsource/fira-code": "^5",
"@fontsource/hanken-grotesk": "^5.2.8",
"@fontsource/inter": "catalog:",
"@fontsource/jetbrains-mono": "^5.2.8",
"@formatjs/intl-segmenter": "^12.0.0",
"@matrix-org/analytics-events": "^0.36.0",
"@matrix-org/emojibase-bindings": "^1.5.0",
+1
View File
@@ -366,3 +366,4 @@
@import "./views/voip/_LegacyCallViewHeader.pcss";
@import "./views/voip/_LegacyCallViewSidebar.pcss";
@import "./views/voip/_VideoFeed.pcss";
@import "./yap/_yap.pcss";
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

+1 -87
View File
@@ -1,87 +1 @@
<svg width="1024" height="1024" viewBox="0 0 1024 1024" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#clip0_178_1078)">
<mask id="mask0_178_1078" style="mask-type:alpha" maskUnits="userSpaceOnUse" x="0" y="-1" width="1024" height="1025">
<rect x="0.5" y="0.499878" width="1023" height="1023" rx="265.5" fill="white" stroke="#8D8D8D"/>
</mask>
<g mask="url(#mask0_178_1078)">
<rect width="1024" height="1024" fill="white"/>
<rect width="1024" height="1024" fill="url(#paint0_linear_178_1078)"/>
</g>
<g filter="url(#filter0_dddd_178_1078)">
<path d="M512 106C736.228 106 918 287.772 918 512C918 736.228 736.228 918 512 918C287.772 918 106 736.228 106 512C106 287.772 287.772 106 512 106Z" fill="url(#paint1_linear_178_1078)"/>
<path d="M512 106C736.228 106 918 287.772 918 512C918 736.228 736.228 918 512 918C287.772 918 106 736.228 106 512C106 287.772 287.772 106 512 106Z" stroke="url(#paint2_linear_178_1078)" stroke-width="8" style="mix-blend-mode:color-burn"/>
<g filter="url(#filter1_di_178_1078)">
<path fill-rule="evenodd" clip-rule="evenodd" d="M437.051 293.072C437.051 276.525 450.493 263.111 467.075 263.111C579.458 263.111 670.564 354.027 670.564 466.178C670.564 482.725 657.122 496.139 640.54 496.139C623.959 496.139 610.517 482.725 610.517 466.178C610.517 387.121 546.296 323.033 467.075 323.033C450.493 323.033 437.051 309.619 437.051 293.072Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M730.866 436.217C747.447 436.217 760.889 449.631 760.889 466.178C760.889 578.328 669.784 669.244 557.4 669.244C540.819 669.244 527.377 655.83 527.377 639.283C527.377 622.737 540.819 609.323 557.4 609.323C636.621 609.323 700.843 545.234 700.843 466.178C700.843 449.631 714.285 436.217 730.866 436.217Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M587.421 730.924C587.421 747.471 573.979 760.884 557.398 760.884C445.014 760.884 353.909 669.968 353.909 557.818C353.909 541.271 367.351 527.857 383.932 527.857C400.513 527.857 413.955 541.271 413.955 557.818C413.955 636.875 478.176 700.963 557.398 700.963C573.979 700.963 587.421 714.377 587.421 730.924Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M293.134 587.779C276.553 587.779 263.111 574.365 263.111 557.818C263.111 445.668 354.216 354.751 466.6 354.751C483.182 354.751 496.623 368.165 496.623 384.712C496.623 401.259 483.182 414.673 466.6 414.673C387.379 414.673 323.158 478.761 323.158 557.818C323.158 574.365 309.716 587.779 293.134 587.779Z" fill="white"/>
</g>
</g>
</g>
<defs>
<filter id="filter0_dddd_178_1078" x="-18" y="78" width="1060" height="1266" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="18"/>
<feGaussianBlur stdDeviation="21"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_178_1078"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="76"/>
<feGaussianBlur stdDeviation="38"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.08 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_178_1078" result="effect2_dropShadow_178_1078"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="170"/>
<feGaussianBlur stdDeviation="51"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.05 0"/>
<feBlend mode="normal" in2="effect2_dropShadow_178_1078" result="effect3_dropShadow_178_1078"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="302"/>
<feGaussianBlur stdDeviation="60"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.01 0"/>
<feBlend mode="normal" in2="effect3_dropShadow_178_1078" result="effect4_dropShadow_178_1078"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect4_dropShadow_178_1078" result="shape"/>
</filter>
<filter id="filter1_di_178_1078" x="243.111" y="247.111" width="537.778" height="537.773" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="4"/>
<feGaussianBlur stdDeviation="10"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.25 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_178_1078"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_178_1078" result="shape"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-16"/>
<feGaussianBlur stdDeviation="11"/>
<feComposite in2="hardAlpha" operator="arithmetic" k2="-1" k3="1"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.596078 0 0 0 0 0.882353 0 0 0 0 0.756863 0 0 0 1 0"/>
<feBlend mode="normal" in2="shape" result="effect2_innerShadow_178_1078"/>
</filter>
<linearGradient id="paint0_linear_178_1078" x1="512" y1="1024" x2="512" y2="0" gradientUnits="userSpaceOnUse">
<stop stop-color="#04B96A" stop-opacity="0.41"/>
<stop offset="0.2" stop-color="#07B661" stop-opacity="0.23"/>
<stop offset="0.4" stop-color="#00B85C" stop-opacity="0.11"/>
<stop offset="0.6" stop-color="#16BB69" stop-opacity="0.06"/>
<stop offset="0.8" stop-color="#16BB79" stop-opacity="0.03"/>
<stop offset="1" stop-color="white" stop-opacity="0"/>
</linearGradient>
<linearGradient id="paint1_linear_178_1078" x1="512" y1="102" x2="512" y2="922" gradientUnits="userSpaceOnUse">
<stop stop-color="#98E1C1"/>
<stop offset="0.33" stop-color="#0BC491"/>
<stop offset="0.66" stop-color="#007A61"/>
<stop offset="1" stop-color="#005C45"/>
</linearGradient>
<linearGradient id="paint2_linear_178_1078" x1="512" y1="102" x2="512" y2="922" gradientUnits="userSpaceOnUse">
<stop stop-color="#16BB79" stop-opacity="0.03"/>
<stop offset="0.25" stop-color="#16BB69" stop-opacity="0.06"/>
<stop offset="0.5" stop-color="#00B85C" stop-opacity="0.11"/>
<stop offset="0.75" stop-color="#07B661" stop-opacity="0.23"/>
<stop offset="1" stop-color="#04B96A" stop-opacity="0.41"/>
</linearGradient>
<clipPath id="clip0_178_1078">
<rect width="1024" height="1024" fill="white"/>
</clipPath>
</defs>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240"><defs><filter id="goo"><feGaussianBlur in="SourceGraphic" stdDeviation="6" result="b"></feGaussianBlur><feColorMatrix in="b" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 26 -11"></feColorMatrix></filter></defs><g filter="url(#goo)"><circle cx="112" cy="126" r="30" fill="#7c66e0"></circle><circle cx="96" cy="74" r="20" fill="#7c66e0"></circle><circle cx="164" cy="108" r="20" fill="#7c66e0"></circle><circle cx="152" cy="176" r="18" fill="#7c66e0"></circle><circle cx="68" cy="168" r="16" fill="#7c66e0"></circle></g></svg>

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 602 B

+3 -3
View File
@@ -1,8 +1,8 @@
{
"name": "Element",
"short_name": "Element",
"name": "Blap",
"short_name": "Blap",
"display": "standalone",
"theme_color": "#76CFA6",
"theme_color": "#7c66e0",
"start_url": "index.html",
"icons": [
{
+2 -1
View File
@@ -1,4 +1,4 @@
@layer compound-tokens, compound-web, shared-components, app-web;
@layer compound-tokens, compound-web, shared-components, app-web, yap;
@import "../../../../res/css/_font-sizes.pcss" layer(app-web);
@import "../../light/css/_fonts.pcss" layer(app-web);
@@ -7,5 +7,6 @@
@import "../../light/css/_mods.pcss" layer(app-web);
@import "../../../../res/css/_components.pcss" layer(app-web);
@import "../../../../res/css/_compound.pcss";
@import "../../../../res/css/yap/_yap.pcss" layer(yap);
@import url("highlight.js/styles/atom-one-dark.min.css") layer(app-web);
@import url("github-markdown-css/github-markdown-dark.css") layer(app-web);
@@ -1,7 +1 @@
<svg width="200" height="200" viewBox="0 0 200 200" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M100 200C155.228 200 200 155.228 200 100C200 44.7715 155.228 0 100 0C44.7715 0 0 44.7715 0 100C0 155.228 44.7715 200 100 200Z" fill="#0DBD8B"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M81.7169 46.5946C81.7169 42.5581 84.9959 39.2859 89.0408 39.2859C116.456 39.2859 138.681 61.4642 138.681 88.8225C138.681 92.859 135.401 96.1312 131.357 96.1312C127.312 96.1312 124.033 92.859 124.033 88.8225C124.033 69.5372 108.366 53.9033 89.0408 53.9033C84.9959 53.9033 81.7169 50.6311 81.7169 46.5946Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M153.39 81.5137C157.435 81.5137 160.714 84.7859 160.714 88.8224C160.714 116.181 138.49 138.359 111.075 138.359C107.03 138.359 103.751 135.087 103.751 131.05C103.751 127.014 107.03 123.742 111.075 123.742C130.4 123.742 146.066 108.108 146.066 88.8224C146.066 84.7859 149.345 81.5137 153.39 81.5137Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M118.398 153.405C118.398 157.442 115.119 160.714 111.074 160.714C83.6592 160.714 61.4347 138.536 61.4347 111.177C61.4347 107.141 64.7138 103.869 68.7587 103.869C72.8035 103.869 76.0826 107.141 76.0826 111.177C76.0826 130.463 91.7489 146.097 111.074 146.097C115.119 146.097 118.398 149.369 118.398 153.405Z" fill="white"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M46.6097 118.486C42.5648 118.486 39.2858 115.214 39.2858 111.178C39.2858 83.8193 61.5102 61.6409 88.9255 61.6409C92.9704 61.6409 96.2494 64.9132 96.2494 68.9497C96.2494 72.9862 92.9704 76.2584 88.9255 76.2584C69.6 76.2584 53.9337 91.8922 53.9337 111.178C53.9337 115.214 50.6546 118.486 46.6097 118.486Z" fill="white"/>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240"><defs><filter id="goo"><feGaussianBlur in="SourceGraphic" stdDeviation="6" result="b"></feGaussianBlur><feColorMatrix in="b" mode="matrix" values="1 0 0 0 0 0 1 0 0 0 0 0 1 0 0 0 0 0 26 -11"></feColorMatrix></filter></defs><rect width="240" height="240" rx="54" fill="#16151c"></rect><g filter="url(#goo)"><circle cx="112" cy="126" r="30" fill="#7c66e0"></circle><circle cx="96" cy="74" r="20" fill="#7c66e0"></circle><circle cx="164" cy="108" r="20" fill="#7c66e0"></circle><circle cx="152" cy="176" r="18" fill="#7c66e0"></circle><circle cx="68" cy="168" r="16" fill="#7c66e0"></circle></g></svg>

Before

Width:  |  Height:  |  Size: 1.7 KiB

After

Width:  |  Height:  |  Size: 663 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

After

Width:  |  Height:  |  Size: 73 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 6.6 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.1 KiB

After

Width:  |  Height:  |  Size: 8.3 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6.5 KiB

After

Width:  |  Height:  |  Size: 8.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 829 B

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 26 KiB

After

Width:  |  Height:  |  Size: 38 KiB

+9 -1
View File
@@ -490,6 +490,7 @@ export default class ContentMessages {
for (let i = 0; i < okFiles.length; ++i) {
const file = okFiles[i];
const loopPromiseBefore = promBefore;
let spoiler = false;
if (!uploadAll) {
const { finished } = Modal.createDialog(UploadConfirmDialog, {
@@ -497,8 +498,9 @@ export default class ContentMessages {
currentIndex: i,
totalFiles: okFiles.length,
});
const [shouldContinue, shouldUploadAll] = await finished;
const [shouldContinue, shouldUploadAll, wantSpoiler] = await finished;
if (!shouldContinue) break;
spoiler = !!wantSpoiler;
if (shouldUploadAll) {
uploadAll = true;
}
@@ -514,6 +516,7 @@ export default class ContentMessages {
matrixClient,
replyToEvent ?? undefined,
loopPromiseBefore,
spoiler,
),
matrixClient,
);
@@ -560,6 +563,7 @@ export default class ContentMessages {
matrixClient: MatrixClient,
replyToEvent: MatrixEvent | undefined,
promBefore?: Promise<any>,
spoiler?: boolean,
): Promise<void> {
const fileName = file.name || _t("common|attachment");
const content: Omit<MediaEventContent, "info"> & { info: Partial<MediaEventInfo> } = {
@@ -569,6 +573,10 @@ export default class ContentMessages {
},
msgtype: MsgType.File, // set more specifically later
};
// Yap: mark this media as a spoiler so it renders blurred behind a reveal.
if (spoiler) {
(content as Record<string, unknown>)["m.yap.spoiler"] = true;
}
// Attach mentions, which really only applies if there's a replyToEvent.
attachMentions(matrixClient.getSafeUserId(), content, null, replyToEvent);
@@ -34,6 +34,7 @@ import { Action } from "../../dispatcher/actions";
import { type XOR } from "../../@types/common";
import ExtensionsCard from "../views/right_panel/ExtensionsCard";
import MemberListView from "../views/rooms/MemberList/MemberListView";
import { YapBridgesView } from "../views/right_panel/YapBridgesView";
interface BaseProps {
overwriteCard?: IRightPanelCard; // used to display a custom card and ignoring the RightPanelStore (used for UserView)
@@ -157,6 +158,12 @@ export default class RightPanel extends React.Component<Props, IState> {
}
break;
case RightPanelPhases.Bridges:
if (this.props.room) {
card = <YapBridgesView room={this.props.room} onClose={this.onClose} />;
}
break;
case RightPanelPhases.MemberInfo:
case RightPanelPhases.EncryptionPanel: {
if (!!cardState?.member) {
@@ -535,55 +535,70 @@ export const HierarchyLevel: React.FC<IHierarchyLevelProps> = ({
);
const newParents = new Set(parents).add(root.room_id);
const allRooms = uniqBy(sortedChildren, "room_id");
const leafRooms = allRooms.filter((room) => room.room_type !== RoomType.Space);
const subspaces = allRooms.filter(
(room) => room.room_type === RoomType.Space && !newParents.has(room.room_id),
);
const isJoined = (room: HierarchyRoom): boolean =>
cli.getRoom(room.room_id)?.getMyMembership() === KnownMembership.Join;
const joinedRooms = leafRooms.filter(isJoined);
const unjoinedRooms = leafRooms.filter((room) => !isJoined(room));
const renderRoomTile = (room: HierarchyRoom): JSX.Element => (
<Tile
key={room.room_id}
room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={() => onViewRoomClick(room.room_id, room.room_type as RoomType)}
onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
/>
);
const renderSpaceTile = (room: HierarchyRoom): JSX.Element => (
<Tile
key={room.room_id}
room={room}
numChildRooms={
room.children_state.filter((ev) => {
const child = hierarchy.roomMap.get(ev.state_key);
return child && roomSet.has(child) && !child.room_type;
}).length
}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={() => onViewRoomClick(room.room_id, RoomType.Space)}
onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
>
<HierarchyLevel
root={room}
roomSet={roomSet}
hierarchy={hierarchy}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onJoinRoomClick={onJoinRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
);
return (
<React.Fragment>
{uniqBy(sortedChildren, "room_id").map((room) => {
if (room.room_type !== RoomType.Space) {
return (
<Tile
key={room.room_id}
room={room}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={() => onViewRoomClick(room.room_id, room.room_type as RoomType)}
onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
/>
);
} else {
if (newParents.has(room.room_id)) return null; // prevent cycles
return (
<Tile
key={room.room_id}
room={room}
numChildRooms={
room.children_state.filter((ev) => {
const child = hierarchy.roomMap.get(ev.state_key);
return child && roomSet.has(child) && !child.room_type;
}).length
}
suggested={hierarchy.isSuggested(root.room_id, room.room_id)}
selected={selectedMap?.get(root.room_id)?.has(room.room_id)}
onViewRoomClick={() => onViewRoomClick(room.room_id, RoomType.Space)}
onJoinRoomClick={() => onJoinRoomClick(room.room_id, newParents)}
hasPermissions={hasPermissions}
onToggleClick={onToggleClick ? () => onToggleClick(root.room_id, room.room_id) : undefined}
>
<HierarchyLevel
root={room}
roomSet={roomSet}
hierarchy={hierarchy}
parents={newParents}
selectedMap={selectedMap}
onViewRoomClick={onViewRoomClick}
onJoinRoomClick={onJoinRoomClick}
onToggleClick={onToggleClick}
/>
</Tile>
);
}
})}
{unjoinedRooms.length > 0 && (
<h4 className="mx_YapHierarchy_sectionHeader">Rooms you haven't joined</h4>
)}
{unjoinedRooms.map(renderRoomTile)}
{joinedRooms.length > 0 && <h4 className="mx_YapHierarchy_sectionHeader">Your rooms</h4>}
{joinedRooms.map(renderRoomTile)}
{subspaces.length > 0 && <h4 className="mx_YapHierarchy_sectionHeader">Spaces</h4>}
{subspaces.map(renderSpaceTile)}
</React.Fragment>
);
};
@@ -292,6 +292,12 @@ const SpaceLanding: React.FC<{ space: Room }> = ({ space }) => {
</div>
<RoomTopic room={space} className="mx_SpaceRoomView_landing_topic" />
<div className="mx_YapSpaceExplainer">
This is a <strong>space</strong>, not a server joining it doesn't drop you into every room.
Browse below and join only the rooms you want; each one is its own separate, encrypted
conversation.
</div>
<SpaceHierarchy space={space} showRoom={showRoom} additionalButtons={addRoomButton} />
</div>
);
@@ -28,6 +28,7 @@ import SSOButtons from "../../views/elements/SSOButtons";
import ServerPicker from "../../views/elements/ServerPicker";
import AuthBody from "../../views/auth/AuthBody";
import AuthHeader from "../../views/auth/AuthHeader";
import SdkConfig from "../../../SdkConfig";
import AccessibleButton, { type ButtonEvent } from "../../views/elements/AccessibleButton";
import { type ValidatedServerConfig } from "../../../utils/ValidatedServerConfig";
import { filterBoolean } from "../../../utils/arrays";
@@ -532,10 +533,23 @@ class LoginComponent extends React.PureComponent<IProps, IState> {
<AuthPage>
<AuthHeader disableLanguageSelector={this.props.isSyncing || this.state.busyLoggingIn} />
<AuthBody>
<h1>
{_t("action|sign_in")}
{loader}
</h1>
<div className="mx_AuthBody_blapWelcome">
<img
className="mx_AuthBody_blapWelcome_mark"
src={
SdkConfig.getObject("branding")?.get("auth_header_logo_url") ??
"themes/element/img/logos/element-logo.svg"
}
alt=""
/>
<h1 className="mx_AuthBody_blapWelcome_title">
{`Welcome to ${SdkConfig.get("brand")}`}
{loader}
</h1>
<div className="mx_AuthBody_blapWelcome_tagline">
{"Cozy chat & calls for you and your people."}
</div>
</div>
{errorTextSection}
{serverDeadSection}
<ServerPicker
@@ -29,6 +29,7 @@ import { DefaultTagID } from "../../../stores/room-list-v3/skip-list/tag";
import { useEventEmitterState } from "../../../hooks/useEventEmitter";
import RightPanelStore from "../../../stores/right-panel/RightPanelStore";
import { RightPanelPhases } from "../../../stores/right-panel/RightPanelStorePhases";
import BridgeSettingsTab from "../../views/settings/tabs/room/BridgeSettingsTab";
import PosthogTrackers from "../../../PosthogTrackers";
import { PollHistoryDialog } from "../../views/dialogs/PollHistoryDialog";
import Modal from "../../../Modal";
@@ -93,6 +94,8 @@ export interface RoomSummaryCardState {
onRoomFilesClick: () => void;
onRoomExtensionsClick: () => void;
onRoomPinsClick: () => void;
onRoomBridgesClick: () => void;
hasBridges: boolean;
onRoomSettingsClick: (ev: Event) => void;
onLeaveRoomClick: () => void;
onShareRoomClick: () => void;
@@ -199,6 +202,12 @@ export function useRoomSummaryCardViewModel(
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.Extensions }, true);
};
// Yap reskin: surface bridges (static info) when the room has any.
const hasBridges = BridgeSettingsTab.getBridgeStateEvents(cli, room.roomId).length > 0;
const onRoomBridgesClick = (): void => {
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.Bridges }, true);
};
const onRoomPinsClick = (): void => {
PosthogTrackers.trackInteraction("PinnedMessageRoomInfoButton");
RightPanelStore.instance.pushCard({ phase: RightPanelPhases.PinnedMessages }, true);
@@ -276,6 +285,8 @@ export function useRoomSummaryCardViewModel(
onRoomFilesClick,
onRoomExtensionsClick,
onRoomPinsClick,
onRoomBridgesClick,
hasBridges,
onRoomSettingsClick,
onLeaveRoomClick,
onShareRoomClick,
@@ -19,11 +19,12 @@ interface IProps {
file: File;
currentIndex: number;
totalFiles: number;
onFinished: (uploadConfirmed: boolean, uploadAll?: boolean) => void;
onFinished: (uploadConfirmed: boolean, uploadAll?: boolean, spoiler?: boolean) => void;
}
interface IState {
objectUrl?: string;
spoiler: boolean;
}
export default class UploadConfirmDialog extends React.Component<IProps, IState> {
@@ -35,7 +36,7 @@ export default class UploadConfirmDialog extends React.Component<IProps, IState>
public constructor(props: IProps) {
super(props);
this.state = {};
this.state = { spoiler: false };
}
public componentDidMount(): void {
@@ -58,11 +59,11 @@ export default class UploadConfirmDialog extends React.Component<IProps, IState>
};
private onUploadClick = (): void => {
this.props.onFinished(true);
this.props.onFinished(true, false, this.state.spoiler);
};
private onUploadAllClick = (): void => {
this.props.onFinished(true, true);
this.props.onFinished(true, true, this.state.spoiler);
};
public render(): React.ReactNode {
@@ -127,6 +128,17 @@ export default class UploadConfirmDialog extends React.Component<IProps, IState>
</div>
</div>
{(mimeType.startsWith("image/") || mimeType.startsWith("video/")) && (
<label className="mx_UploadConfirmDialog_spoiler">
<input
type="checkbox"
checked={this.state.spoiler}
onChange={(ev) => this.setState({ spoiler: ev.target.checked })}
/>
<span>Mark as spoiler (blur until clicked)</span>
</label>
)}
<DialogButtons
primaryButton={_t("action|upload")}
hasCancel={false}
@@ -253,6 +253,9 @@ const RoomSummaryCardView: React.FC<IProps> = ({
<MenuItem Icon={UserProfileIcon} label={_t("common|people")} onSelect={vm.onRoomMembersClick} />
<MenuItem Icon={ThreadsIcon} label={_t("common|threads")} onSelect={vm.onRoomThreadsClick} />
{vm.hasBridges && (
<MenuItem Icon={ExtensionsIcon} label="Bridges" onSelect={vm.onRoomBridgesClick} />
)}
{!vm.isVideoRoom && (
<>
<MenuItem
@@ -0,0 +1,39 @@
/*
Yap reskin — Bridges right-panel card.
Shows STATIC bridge topology (protocol / network / channel / bot) read from
`m.bridge` / `uk.half-shot.bridge` room state, reusing Element's own BridgeTile.
Live connection / login status ("connected", "session expired", "reconnect") is
NOT exposed to Matrix clients — it lives in the bridge bot's management room, not
in client-readable state — so it is intentionally omitted (see scoping doc).
*/
import React, { type FC, type MouseEvent } from "react";
import { type Room } from "matrix-js-sdk/src/matrix";
import BaseCard from "./BaseCard";
import BridgeTile from "../settings/BridgeTile";
import BridgeSettingsTab from "../settings/tabs/room/BridgeSettingsTab";
interface Props {
room: Room;
onClose?: (ev: MouseEvent<HTMLButtonElement>) => void;
}
export const YapBridgesView: FC<Props> = ({ room, onClose }) => {
const events = BridgeSettingsTab.getBridgeStateEvents(room.client, room.roomId);
return (
<BaseCard header="Bridges" className="mx_YapBridgesView" onClose={onClose}>
<div className="mx_YapBridgesView_list">
{events.length === 0 ? (
<p className="mx_YapBridgesView_empty">No bridges in this room.</p>
) : (
events.map((ev) => <BridgeTile key={ev.getId()} room={room} ev={ev} />)
)}
</div>
</BaseCard>
);
};
export default YapBridgesView;
@@ -1263,7 +1263,14 @@ export class UnwrappedEventTile extends React.Component<EventTileProps, IState>
},
<>
{ircTimestamp}
{sender}
{this.props.layout === Layout.Group && eventTileSnapshot.sender.profileMode !== "hidden" ? (
<div className="mx_EventTile_senderRow">
{sender}
{groupTimestamp}
</div>
) : (
sender
)}
{ircPadlock}
{avatar}
<div
@@ -25,6 +25,7 @@ import { CallType } from "matrix-js-sdk/src/webrtc/call";
import { HistoryIcon, UserProfileSolidIcon } from "@vector-im/compound-design-tokens/assets/web/icons";
import { useRoomName } from "../../../../hooks/useRoomName.ts";
import { useTopic } from "../../../../hooks/room/useTopic.ts";
import { RightPanelPhases } from "../../../../stores/right-panel/RightPanelStorePhases.ts";
import { useMatrixClientContext } from "../../../../contexts/MatrixClientContext.tsx";
import { useRoomMemberCount, useRoomMembers } from "../../../../hooks/useRoomMembers.ts";
@@ -444,6 +445,7 @@ export default function RoomHeader({
}): JSX.Element {
const client = useMatrixClientContext();
const roomName = useRoomName(room);
const topic = useTopic(room);
const joinRule = useRoomState(room, (state) => state.getJoinRule());
const historyVisibility = useRoomState(room, (state) => state.getHistoryVisibility());
const dmMember = useDmMember(room);
@@ -495,6 +497,11 @@ export default function RoomHeader({
aria-level={1}
className="mx_RoomHeader_heading"
>
{!isDirectMessage && (
<span className="mx_YapHeader_hash" aria-hidden="true">
#
</span>
)}
<span className="mx_RoomHeader_truncated mx_lineClamp">{roomName}</span>
{!isDirectMessage && joinRule === JoinRule.Public && (
@@ -533,6 +540,11 @@ export default function RoomHeader({
{isRoomEncrypted && historyVisibilityIcon(historyVisibility)}
</Text>
{!isDirectMessage && topic?.text && (
<span className="mx_YapHeader_topic" dir="auto">
{topic.text}
</span>
)}
</Box>
</button>
{/* If the room is local-only then we don't want to show any additional buttons, as it won't work */}
@@ -12,6 +12,8 @@ import { shouldShowComponent } from "../../../../customisations/helpers/UICompon
import { UIComponent } from "../../../../settings/UIFeature";
import { RoomListSearch } from "./RoomListSearch";
import { RoomListView } from "./RoomListView";
import { YapRoomList } from "./YapRoomList";
import { YapUserPanel } from "./YapUserPanel";
import { _t } from "../../../../languageHandler";
import { getKeyBindingsManager } from "../../../../KeyBindingsManager";
import { KeyBindingAction } from "../../../../accessibility/KeyboardShortcuts";
@@ -78,7 +80,8 @@ export const RoomListPanel: React.FC<RoomListPanelProps> = ({ activeSpace }) =>
>
{displayRoomSearch && <RoomListSearch activeSpace={activeSpace} />}
<RoomListHeaderView vm={vm} />
<RoomListView />
<YapRoomList />
<YapUserPanel />
</Flex>
);
};
@@ -0,0 +1,442 @@
/*
Yap reskin — Discord-style room list.
Sections: INVITES, TEXT/VOICE channels, DIRECT MESSAGES.
Supports per-space manual drag-and-drop ordering (stored device-locally),
falling back to RoomListStoreV3's default sort for any unplaced room.
Call presence via CallStore (read-only).
*/
import React, { useEffect, useRef, useState, type JSX } from "react";
import { type Room, EventType, RoomStateEvent, KnownMembership } from "matrix-js-sdk/src/matrix";
import RoomListStoreV3, { LISTS_UPDATE_EVENT } from "../../../../stores/room-list-v3/RoomListStoreV3";
import defaultDispatcher from "../../../../dispatcher/dispatcher";
import { Action } from "../../../../dispatcher/actions";
import { isVideoRoom } from "../../../../utils/video-rooms";
import DMRoomMap from "../../../../utils/DMRoomMap";
import RoomAvatar from "../../avatars/RoomAvatar";
import MemberAvatar from "../../avatars/MemberAvatar";
import AudioIcon from "@vector-im/compound-design-tokens/assets/web/icons/audio";
import { SdkContextClass } from "../../../../contexts/SDKContext";
import { RoomNotificationStateStore } from "../../../../stores/notifications/RoomNotificationStateStore";
import { UPDATE_EVENT } from "../../../../stores/AsyncStore";
import { useCall, useParticipatingMembers } from "../../../../hooks/useCall";
import SpaceStore from "../../../../stores/spaces/SpaceStore";
import ExploreIcon from "@vector-im/compound-design-tokens/assets/web/icons/explore";
function currentRooms(): Room[] {
return RoomListStoreV3.instance
.getSortedRoomsInActiveSpace()
.sections.flatMap((s) => s.rooms)
.filter((room) => {
const m = room.getMyMembership();
return m === KnownMembership.Join || m === KnownMembership.Invite;
});
}
function isDm(room: Room): boolean {
return !!DMRoomMap.shared()?.getUserIdForRoomId(room.roomId);
}
// ---- bridge platform detection (which network a DM/room comes from) ----
type Platform = "telegram" | "discord" | "instagram" | "messenger" | "whatsapp" | "signal" | "matrix";
const PLATFORM_META: Record<Exclude<Platform, "matrix">, { label: string; color: string; name: string }> = {
telegram: { label: "TG", color: "#229ED9", name: "Telegram" },
discord: { label: "DC", color: "#5865F2", name: "Discord" },
instagram: { label: "IG", color: "#E1306C", name: "Instagram" },
messenger: { label: "FB", color: "#0084FF", name: "Messenger" },
whatsapp: { label: "WA", color: "#25D366", name: "WhatsApp" },
signal: { label: "SG", color: "#3A76F0", name: "Signal" },
};
function matchProtocol(id: string): Platform | null {
if (id.includes("telegram")) return "telegram";
if (id.includes("discord")) return "discord";
if (id.includes("instagram")) return "instagram";
if (id.includes("messenger") || id.includes("facebook") || id === "meta") return "messenger";
if (id.includes("whatsapp")) return "whatsapp";
if (id.includes("signal")) return "signal";
return null;
}
/** Best-effort detection of which network a room is bridged from. */
function roomPlatform(room: Room): Platform {
// 1) bridge state event (mautrix/half-shot set protocol.id, e.g. "telegram")
const bridgeEvents = [
...room.currentState.getStateEvents("uk.half-shot.bridge"),
...room.currentState.getStateEvents("m.bridge"),
];
for (const ev of bridgeEvents) {
const id = String((ev.getContent() as { protocol?: { id?: unknown } })?.protocol?.id ?? "").toLowerCase();
const p = matchProtocol(id);
if (p) return p;
}
// 2) fall back to the DM partner's puppet user-id (e.g. @telegram_123:server)
const partner = DMRoomMap.shared()?.getUserIdForRoomId(room.roomId) ?? "";
const localpart = partner.split(":")[0].replace(/^@/, "").toLowerCase();
return matchProtocol(localpart) ?? "matrix";
}
function PlatformBadge({ room }: { room: Room }): JSX.Element | null {
const platform = roomPlatform(room);
if (platform === "matrix") return null;
const meta = PLATFORM_META[platform];
return (
<span className="mx_YapPlatformBadge" style={{ backgroundColor: meta.color }} title={meta.name}>
{meta.label}
</span>
);
}
function viewRoom(roomId: string): void {
defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: roomId, metricsTrigger: "RoomList" });
}
/** All child room IDs currently listed in a space (joined or not). */
function spaceChildRoomIds(spaceRoom: Room | null | undefined): string[] {
if (!spaceRoom) return [];
return spaceRoom.currentState
.getStateEvents(EventType.SpaceChild)
.filter((ev) => !!ev.getContent().via)
.map((ev) => ev.getStateKey())
.filter((k): k is string => !!k);
}
const seenKey = (spaceId: string): string => `yap_seen_rooms_${spaceId}`;
function getSeenRooms(spaceId: string): Set<string> {
try {
return new Set(JSON.parse(localStorage.getItem(seenKey(spaceId)) ?? "[]"));
} catch {
return new Set();
}
}
function markRoomsSeen(spaceId: string, ids: string[]): void {
try {
localStorage.setItem(seenKey(spaceId), JSON.stringify(ids));
} catch {
/* ignore quota errors */
}
}
// ---- manual room order (per space, device-local) ----
const orderKey = (spaceId: string): string => `yap_room_order_${spaceId}`;
function getRoomOrder(spaceId: string): string[] {
try {
return JSON.parse(localStorage.getItem(orderKey(spaceId)) ?? "[]");
} catch {
return [];
}
}
function setRoomOrder(spaceId: string, ids: string[]): void {
try {
localStorage.setItem(orderKey(spaceId), JSON.stringify(ids));
} catch {
/* ignore */
}
}
/** Sort rooms by the manual order; unplaced rooms keep their (stable) default order at the end. */
function applyManualOrder(rooms: Room[], order: string[]): Room[] {
const idx = new Map(order.map((id, i) => [id, i]));
return [...rooms].sort((a, b) => {
const ai = idx.has(a.roomId) ? idx.get(a.roomId)! : Number.MAX_SAFE_INTEGER;
const bi = idx.has(b.roomId) ? idx.get(b.roomId)! : Number.MAX_SAFE_INTEGER;
return ai - bi;
});
}
/** Move draggedId to just before (or after) targetId within ids. `after` lets a
* row be dropped into the final slot of a section, which inserting-before can't reach. */
function reorderIds(ids: string[], draggedId: string, targetId: string, after: boolean): string[] {
const arr = ids.filter((id) => id !== draggedId);
let i = arr.indexOf(targetId);
if (i < 0) return [...arr, draggedId];
if (after) i += 1;
arr.splice(i, 0, draggedId);
return arr;
}
type DropPos = "before" | "after" | null;
function rowClasses(room: Room, activeRoomId: string | null, extra = "", dropPos: DropPos = null): string {
const unread = RoomNotificationStateStore.instance.getRoomState(room).isUnread;
return (
"mx_YapRoomRow" +
extra +
(room.roomId === activeRoomId ? " mx_YapRoomRow_active" : "") +
(unread ? " mx_YapRoomRow_unread" : "") +
(dropPos === "before" ? " mx_YapRoomRow_dropBefore" : "") +
(dropPos === "after" ? " mx_YapRoomRow_dropAfter" : "")
);
}
function RoomBadge({ room }: { room: Room }): JSX.Element | null {
const notif = RoomNotificationStateStore.instance.getRoomState(room);
if (notif.count <= 0) return null;
return (
<span className={"mx_YapRoomRow_badge" + (notif.hasMentions ? " mx_YapRoomRow_badge_mention" : "")}>
{notif.count}
</span>
);
}
type DragProps = React.HTMLAttributes<HTMLDivElement> & { draggable?: boolean };
type RowProps = { room: Room; activeRoomId: string | null; drag: DragProps; dropPos: DropPos };
function ChannelRow({ room, activeRoomId, drag, dropPos }: RowProps): JSX.Element {
return (
<div
className={rowClasses(room, activeRoomId, "", dropPos)}
role="treeitem"
tabIndex={0}
title={room.name}
onClick={() => viewRoom(room.roomId)}
{...drag}
>
<span className="mx_YapRoomRow_glyph">#</span>
<span className="mx_YapRoomRow_name">{room.name}</span>
<RoomBadge room={room} />
</div>
);
}
/** Avatars + names of who is currently in this voice room's call (read-only). */
function VoiceRoomMembers({ room }: { room: Room }): JSX.Element | null {
const call = useCall(room.roomId);
const members = useParticipatingMembers(call);
if (members.length === 0) return null;
return (
<div className="mx_YapVoiceMembers">
{members.map((m) => (
<div className="mx_YapVoiceMember" key={m.userId} title={m.name}>
<MemberAvatar member={m} size="22px" hideTitle />
<span className="mx_YapVoiceMember_name">{m.name}</span>
</div>
))}
</div>
);
}
function VoiceRoomRow({ room, activeRoomId, drag, dropPos }: RowProps): JSX.Element {
return (
<div className="mx_YapVoiceRoom">
<div
className={rowClasses(room, activeRoomId, " mx_YapRoomRow_voice", dropPos)}
role="treeitem"
tabIndex={0}
title={room.name}
onClick={() => viewRoom(room.roomId)}
{...drag}
>
<AudioIcon className="mx_YapRoomRow_glyphSvg" width="18" height="18" />
<span className="mx_YapRoomRow_name">{room.name}</span>
<RoomBadge room={room} />
</div>
<VoiceRoomMembers room={room} />
</div>
);
}
function DmRow({ room, activeRoomId, drag, dropPos }: RowProps): JSX.Element {
return (
<div
className={rowClasses(room, activeRoomId, " mx_YapRoomRow_dm", dropPos)}
role="treeitem"
tabIndex={0}
title={room.name}
onClick={() => viewRoom(room.roomId)}
{...drag}
>
<RoomAvatar room={room} size="24px" />
<span className="mx_YapRoomRow_name">{room.name}</span>
<PlatformBadge room={room} />
<RoomBadge room={room} />
</div>
);
}
function InviteRow({ room }: { room: Room }): JSX.Element {
return (
<div
className="mx_YapRoomRow mx_YapRoomRow_invite"
role="treeitem"
tabIndex={0}
title={room.name}
onClick={() => viewRoom(room.roomId)}
>
<RoomAvatar room={room} size="24px" />
<span className="mx_YapRoomRow_name">{room.name}</span>
<span className="mx_YapRoomRow_inviteBadge">Invite</span>
</div>
);
}
export function YapRoomList(): JSX.Element {
const [rooms, setRooms] = useState<Room[]>(currentRooms);
const [activeRoomId, setActiveRoomId] = useState<string | null>(() =>
SdkContextClass.instance.roomViewStore.getRoomId(),
);
const [, setTick] = useState(0);
const dragId = useRef<string | null>(null);
const [dragOver, setDragOver] = useState<{ id: string; after: boolean } | null>(null);
useEffect(() => {
const update = (): void => {
setRooms(currentRooms());
setTick((t) => t + 1);
};
RoomListStoreV3.instance.on(LISTS_UPDATE_EVENT, update);
update();
return () => {
RoomListStoreV3.instance.off(LISTS_UPDATE_EVENT, update);
};
}, []);
useEffect(() => {
const rvs = SdkContextClass.instance.roomViewStore;
const onActive = (): void => setActiveRoomId(rvs.getRoomId());
rvs.on(UPDATE_EVENT, onActive);
return () => {
rvs.off(UPDATE_EVENT, onActive);
};
}, []);
const activeSpaceRoom = SpaceStore.instance.activeSpaceRoom;
// Use the active space KEY (not the room id) so manual ordering also works in
// Home and other meta-spaces, where activeSpaceRoom is null.
const spaceId = SpaceStore.instance.activeSpace;
const invites = rooms.filter((r) => r.getMyMembership() === KnownMembership.Invite);
const joinedRaw = rooms.filter((r) => r.getMyMembership() === KnownMembership.Join);
const joined = spaceId ? applyManualOrder(joinedRaw, getRoomOrder(spaceId)) : joinedRaw;
const voice = joined.filter((r) => !isDm(r) && isVideoRoom(r));
const channels = joined.filter((r) => !isDm(r) && !isVideoRoom(r));
const dms = joined.filter(isDm);
const joinedOrderIds = joined.map((r) => r.roomId);
// Drag-and-drop manual ordering. Returns the draggable props for a room row.
// Drop position (before/after the target) is derived from the cursor's vertical
// position within the row, so a row can be dropped into the last slot.
const dropAfterForEvent = (e: React.DragEvent<HTMLDivElement>): boolean => {
const rect = e.currentTarget.getBoundingClientRect();
return e.clientY > rect.top + rect.height / 2;
};
const makeDrag = (roomId: string): DragProps => ({
draggable: true,
onDragStart: (e) => {
dragId.current = roomId;
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setData("text/plain", roomId); // required for Firefox to start the drag
},
onDragOver: (e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
if (!dragId.current || dragId.current === roomId) return;
const after = dropAfterForEvent(e);
setDragOver((prev) => (prev?.id === roomId && prev.after === after ? prev : { id: roomId, after }));
},
onDragEnd: () => {
dragId.current = null;
setDragOver(null);
},
onDrop: (e) => {
e.preventDefault();
const dragged = dragId.current;
const after = dropAfterForEvent(e);
dragId.current = null;
setDragOver(null);
if (!dragged || dragged === roomId || !spaceId) return;
setRoomOrder(spaceId, reorderIds(joinedOrderIds, dragged, roomId, after));
setTick((t) => t + 1);
},
});
const childIds = spaceChildRoomIds(activeSpaceRoom);
const seen = activeSpaceRoom ? getSeenRooms(activeSpaceRoom.roomId) : new Set<string>();
const newRoomCount = activeSpaceRoom ? childIds.filter((id) => !seen.has(id)).length : 0;
const newRoomLabel =
newRoomCount > 0
? `Since you last looked, ${newRoomCount} new room${newRoomCount === 1 ? "" : "s"}`
: undefined;
const onBrowse = (): void => {
if (!activeSpaceRoom) return;
markRoomsSeen(activeSpaceRoom.roomId, spaceChildRoomIds(activeSpaceRoom));
viewRoom(activeSpaceRoom.roomId);
setTick((t) => t + 1);
};
useEffect(() => {
const space = SpaceStore.instance.activeSpaceRoom;
if (!space) return;
if (localStorage.getItem(seenKey(space.roomId)) === null) {
markRoomsSeen(space.roomId, spaceChildRoomIds(space));
}
const onState = (): void => setTick((t) => t + 1);
space.currentState.on(RoomStateEvent.Update, onState);
return () => {
space.currentState.off(RoomStateEvent.Update, onState);
};
}, [activeSpaceRoom?.roomId]);
return (
<div className="mx_YapRoomList" role="tree">
{activeSpaceRoom && (
<div
className="mx_YapBrowseRooms"
role="treeitem"
tabIndex={0}
onClick={onBrowse}
title={newRoomLabel}
>
<ExploreIcon className="mx_YapRoomRow_glyphSvg" width="18" height="18" />
<span className="mx_YapBrowseRooms_label">Browse rooms</span>
{newRoomCount > 0 && (
<span className="mx_YapBrowseRooms_count" aria-label={newRoomLabel}>
{newRoomCount}
</span>
)}
</div>
)}
{invites.length > 0 && <div className="mx_YapRoomList_header">Invites</div>}
{invites.map((room) => (
<InviteRow key={room.roomId} room={room} />
))}
{channels.length > 0 && <div className="mx_YapRoomList_header">Text Channels</div>}
{channels.map((room) => (
<ChannelRow
key={room.roomId}
room={room}
activeRoomId={activeRoomId}
drag={makeDrag(room.roomId)}
dropPos={dragOver?.id === room.roomId ? (dragOver.after ? "after" : "before") : null}
/>
))}
{voice.length > 0 && <div className="mx_YapRoomList_header">Voice Channels</div>}
{voice.map((room) => (
<VoiceRoomRow
key={room.roomId}
room={room}
activeRoomId={activeRoomId}
drag={makeDrag(room.roomId)}
dropPos={dragOver?.id === room.roomId ? (dragOver.after ? "after" : "before") : null}
/>
))}
{dms.length > 0 && <div className="mx_YapRoomList_header">Direct Messages</div>}
{dms.map((room) => (
<DmRow
key={room.roomId}
room={room}
activeRoomId={activeRoomId}
drag={makeDrag(room.roomId)}
dropPos={dragOver?.id === room.roomId ? (dragOver.after ? "after" : "before") : null}
/>
))}
</div>
);
}
export default YapRoomList;
@@ -0,0 +1,82 @@
/*
Yap reskin — bottom user panel + voice-connected strip.
Shows the logged-in user (avatar/name) with a settings button, and — when you're
connected to a call — a "Voice Connected" strip that deep-links back to the call
room. (Live ping / external mute controls aren't available outside the call
widget, so this is status + return, by design.)
*/
import React, { useEffect, useState, type JSX } from "react";
import { MatrixClientPeg } from "../../../../MatrixClientPeg";
import { OwnProfileStore } from "../../../../stores/OwnProfileStore";
import { CallStore, CallStoreEvent } from "../../../../stores/CallStore";
import { type Call } from "../../../../models/Call";
import { UPDATE_EVENT } from "../../../../stores/AsyncStore";
import defaultDispatcher from "../../../../dispatcher/dispatcher";
import { Action } from "../../../../dispatcher/actions";
import BaseAvatar from "../../avatars/BaseAvatar";
import SettingsIcon from "@vector-im/compound-design-tokens/assets/web/icons/settings";
import AudioIcon from "@vector-im/compound-design-tokens/assets/web/icons/audio";
import { ThreadsActivityCentre } from "../../spaces/threads-activity-centre";
export function YapUserPanel(): JSX.Element {
const cli = MatrixClientPeg.get();
const [, setTick] = useState(0);
const [connected, setConnected] = useState<Call[]>(() => Array.from(CallStore.instance.connectedCalls));
useEffect(() => {
const onProfile = (): void => setTick((t) => t + 1);
OwnProfileStore.instance.on(UPDATE_EVENT, onProfile);
const onCalls = (calls: Set<Call>): void => setConnected(Array.from(calls));
CallStore.instance.on(CallStoreEvent.ConnectedCalls, onCalls);
return () => {
OwnProfileStore.instance.off(UPDATE_EVENT, onProfile);
CallStore.instance.off(CallStoreEvent.ConnectedCalls, onCalls);
};
}, []);
const userId = cli?.getSafeUserId();
const name = OwnProfileStore.instance.displayName ?? userId ?? "";
const avatarUrl = OwnProfileStore.instance.getHttpAvatarUrl(34);
const call = connected[0];
const callRoomName = call ? (cli?.getRoom(call.roomId)?.name ?? "call") : undefined;
return (
<div className="mx_YapUserPanel">
{call && (
<div
className="mx_YapVoiceConnected"
role="button"
tabIndex={0}
onClick={() => defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: call.roomId })}
>
<AudioIcon className="mx_YapVoiceConnected_icon" width="16" height="16" />
<div className="mx_YapVoiceConnected_text">
<span className="mx_YapVoiceConnected_label">Voice Connected</span>
<span className="mx_YapVoiceConnected_room">{callRoomName}</span>
</div>
</div>
)}
<div className="mx_YapUserPanel_row">
<BaseAvatar name={name} idName={userId} url={avatarUrl} size="34px" />
<div className="mx_YapUserPanel_info">
<span className="mx_YapUserPanel_name">{name}</span>
{userId && <span className="mx_YapUserPanel_id">{userId}</span>}
</div>
<div className="mx_YapUserPanel_threads">
<ThreadsActivityCentre displayButtonLabel={false} />
</div>
<button
className="mx_YapUserPanel_btn"
title="Settings"
onClick={() => defaultDispatcher.dispatch({ action: Action.ViewUserSettings })}
>
<SettingsIcon width="20" height="20" />
</button>
</div>
</div>
);
}
export default YapUserPanel;
@@ -41,6 +41,7 @@ import { RoomGeneralContextMenu } from "../context_menus/RoomGeneralContextMenu"
import { CallStore, CallStoreEvent } from "../../../stores/CallStore";
import { SdkContextClass } from "../../../contexts/SDKContext";
import { RoomTileSubtitle } from "./RoomTileSubtitle";
import { YapCallFacepile } from "./YapCallFacepile";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { isKnockDenied } from "../../../utils/membership";
@@ -469,6 +470,9 @@ class RoomTile extends React.PureComponent<Props, State> {
tooltipProps={{ tabIndex: isActive ? 0 : -1 }}
/>
{titleContainer}
{this.state.call && !this.props.isMinimized && (
<YapCallFacepile call={this.state.call} />
)}
{badge}
{this.renderGeneralMenu()}
{this.renderNotificationsMenu(isActive)}
@@ -0,0 +1,45 @@
/*
Yap reskin — call-presence facepile.
Read-only avatar stack of who is currently in a room's call. Fed by
useParticipatingMembers(), which derives from MatrixRTC session membership
(org.matrix.msc3401.call.member room state) and updates live via
CallEvent.Participants. No Element Call widget internals are touched — this is
pure re-presentation of existing store state.
Note: live "who is speaking" / audio levels are NOT available outside the call
widget, so this shows membership only (by design).
*/
import React, { type FC } from "react";
import { AvatarStack } from "@vector-im/compound-web";
import { type Call } from "../../../models/Call";
import { useParticipatingMembers } from "../../../hooks/useCall";
import MemberAvatar from "../avatars/MemberAvatar";
interface Props {
call: Call;
}
const MAX_SHOWN = 5;
export const YapCallFacepile: FC<Props> = ({ call }) => {
const members = useParticipatingMembers(call);
if (members.length === 0) return null;
const shown = members.slice(0, MAX_SHOWN);
const extra = members.length - shown.length;
return (
<div className="mx_YapCallFacepile" aria-label={`${members.length} in call`}>
<AvatarStack>
{shown.map((m) => (
<MemberAvatar key={m.userId} member={m} size="22px" hideTitle />
))}
</AvatarStack>
{extra > 0 && <span className="mx_YapCallFacepile_more">+{extra}</span>}
</div>
);
};
export default YapCallFacepile;
@@ -208,6 +208,11 @@ export default class VoiceUserSettingsTab extends React.Component<EmptyObject, I
<SettingsSubsection heading={_t("settings|voip|video_section")} stretchContent>
{webcamDropdown}
<SettingsFlag name="VideoView.flipVideoHorizontally" level={SettingLevel.ACCOUNT} />
<SettingsFlag
name="blapJoinCameraOff"
level={SettingLevel.DEVICE}
label="Join calls with my camera off"
/>
</SettingsSubsection>
</SettingsSection>
@@ -31,7 +31,6 @@ import {
VideoCallSolidIcon,
UserProfileSolidIcon,
PlusIcon,
ChevronRightIcon,
} from "@vector-im/compound-design-tokens/assets/web/icons";
import { useCreateAutoDisposedViewModel, UserMenu } from "@element-hq/web-shared-components";
@@ -76,9 +75,7 @@ import { getKeyBindingsManager } from "../../../KeyBindingsManager";
import { shouldShowComponent } from "../../../customisations/helpers/UIComponents";
import { UIComponent } from "../../../settings/UIFeature";
import { ThreadsActivityCentre } from "./threads-activity-centre/";
import AccessibleButton from "../elements/AccessibleButton";
import { Landmark, LandmarkNavigation } from "../../../accessibility/LandmarkNavigation";
import { KeyboardShortcut } from "../settings/KeyboardShortcut";
import { ModuleApi } from "../../../modules/Api.ts";
import { useModuleSpacePanelItems } from "../../../modules/ExtrasApi.ts";
import { UserMenuViewModel } from "../../../viewmodels/menus/UserMenuViewModel.ts";
@@ -391,7 +388,11 @@ const InnerSpacePanel = React.memo<IInnerSpacePanelProps>(
const SpacePanel: React.FC = () => {
const client = useMatrixClientContext();
const [dragging, setDragging] = useState(false);
const [isPanelCollapsed, setPanelCollapsed] = useState(true);
// Blap: the spaces rail is permanently icon-only. Collapse/expand is disabled so
// the rail can never enter the broken "expanded" state (overlapping 3-dot menus,
// clipped labels). isPanelCollapsed is locked true and the setter is a no-op.
const isPanelCollapsed = true;
const setPanelCollapsed: Dispatch<SetStateAction<boolean>> = () => {};
const ref = useRef<HTMLDivElement>(null);
useLayoutEffect(() => {
if (ref.current) UIStore.instance.trackElementDimensions("SpacePanel", ref.current);
@@ -466,21 +467,6 @@ const SpacePanel: React.FC = () => {
aria-label={_t("common|spaces")}
>
<UserMenu vm={userMenuVm} className="mx_UserMenu" />
<AccessibleButton
className={classNames("mx_SpacePanel_toggleCollapse", {
expanded: !isPanelCollapsed,
})}
onClick={() => setPanelCollapsed(!isPanelCollapsed)}
title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")}
caption={
<KeyboardShortcut
value={{ ctrlOrCmdKey: true, shiftKey: true, key: "d" }}
className="mx_SpacePanel_Tooltip_KeyboardShortcut"
/>
}
>
<ChevronRightIcon />
</AccessibleButton>
<Droppable droppableId="top-level-spaces">
{(provided, snapshot) => (
<InnerSpacePanel
+29 -11
View File
@@ -42,15 +42,33 @@ export function useMediaVisible(mxEvent?: MatrixEvent): [boolean, (visible: bool
[mxEvent],
);
return [
computeMediaVisibility(
mediaPreviewSetting,
eventVisibility,
client.getUserId() ?? undefined,
mxEvent?.getId(),
mxEvent?.getSender(),
joinRule ? [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted].includes(joinRule) : false,
),
setMediaVisible,
];
const baseVisible = computeMediaVisibility(
mediaPreviewSetting,
eventVisibility,
client.getUserId() ?? undefined,
mxEvent?.getId(),
mxEvent?.getSender(),
joinRule ? [JoinRule.Invite, JoinRule.Knock, JoinRule.Restricted].includes(joinRule) : false,
);
// Yap: image/media spoilers. A spoiler-flagged event is hidden (blurred behind the
// "Show image" reveal) by default — unless the user has explicitly revealed it, in
// which case the per-event override in showMediaEventIds takes over.
const eventId = mxEvent?.getId();
const isSpoiler = !!mxEvent && isSpoilerMedia(mxEvent);
const explicitlyToggled = eventId ? eventVisibility[eventId] : undefined;
const visible = isSpoiler && explicitlyToggled === undefined ? false : baseVisible;
return [visible, setMediaVisible];
}
/** Whether a media event is flagged as a spoiler / content warning. */
export function isSpoilerMedia(mxEvent: MatrixEvent): boolean {
const content = mxEvent.getContent();
return (
content["m.yap.spoiler"] === true ||
// common content-warning conventions used by some bridges/clients
content["town.robin.msc3725.content_warning"] !== undefined ||
content["m.content_warning"] !== undefined
);
}
+8
View File
@@ -984,6 +984,14 @@ export class ElementCall extends Call {
ev.preventDefault();
this.widgetApi!.transport.reply(ev.detail, {}); // ack
this.setConnected();
// Blap: optionally join with the camera muted. EC accepts a toWidget
// device_mute request ({ audio_enabled?, video_enabled? }); undefined
// fields are left as-is, so this only forces video off.
if (SettingsStore.getValue("blapJoinCameraOff")) {
this.widgetApi!.transport.send(ElementWidgetActions.DeviceMute, { video_enabled: false }).catch((e) => {
console.warn("Blap: failed to mute camera on join", e);
});
}
};
private readonly onHangup = async (ev: CustomEvent<IWidgetApiRequest>): Promise<void> => {
+9 -1
View File
@@ -293,6 +293,7 @@ export interface Settings {
"webrtc_audio_autoGainControl": IBaseSetting<boolean>;
"webrtc_audio_echoCancellation": IBaseSetting<boolean>;
"webrtc_audio_noiseSuppression": IBaseSetting<boolean>;
"blapJoinCameraOff": IBaseSetting<boolean>;
"language": IBaseSetting<string>;
"breadcrumb_rooms": IBaseSetting<string[]>;
"recent_emoji": IBaseSetting<RecentEmojiData>;
@@ -1056,6 +1057,11 @@ export const SETTINGS: Settings = {
displayName: _td("settings|voip|noise_suppression"),
default: true,
},
"blapJoinCameraOff": {
// label is provided directly on the SettingsFlag (avoids needing an i18n key)
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS,
default: true,
},
"language": {
supportedLevels: LEVELS_DEVICE_ONLY_SETTINGS_WITH_CONFIG,
default: "en",
@@ -1175,7 +1181,9 @@ export const SETTINGS: Settings = {
supportedLevels: [SettingLevel.DEVICE],
supportedLevelsAreOrdered: true,
displayName: _td("settings|inline_url_previews_encrypted"),
default: false,
// Blap: previews on by default in encrypted rooms too (social client; users can still
// toggle per-device). Trade-off: the homeserver fetches link metadata for E2EE messages.
default: true,
controller: new RequiresSettingsController([UIFeature.URLPreviews, "urlPreviewsEnabled"]),
},
"notificationsEnabled": {
@@ -24,6 +24,7 @@ export enum RightPanelPhases {
PinnedMessages = "PinnedMessages",
Timeline = "Timeline",
Extensions = "Extensions",
Bridges = "Bridges", // Yap reskin: static bridge info panel
// Thread stuff
ThreadView = "ThreadView",
+9
View File
@@ -21,6 +21,15 @@ import "@fontsource/fira-code/latin-400.css";
import "@fontsource/fira-code/latin-ext-700.css";
import "@fontsource/fira-code/latin-700.css";
// Yap reskin fonts — bundled locally (no Google CDN dependency)
import "@fontsource/hanken-grotesk/400.css";
import "@fontsource/hanken-grotesk/500.css";
import "@fontsource/hanken-grotesk/600.css";
import "@fontsource/hanken-grotesk/700.css";
import "@fontsource/hanken-grotesk/800.css";
import "@fontsource/jetbrains-mono/400.css";
import "@fontsource/jetbrains-mono/500.css";
import { logger } from "matrix-js-sdk/src/logger";
import { _t } from "./languageHandler";
+4 -3
View File
@@ -2,7 +2,8 @@
<html lang="en" style="height: 100%;">
<head>
<meta charset="utf-8">
<title>Element</title>
<title>Blap</title>
<!-- Yap reskin fonts are bundled locally via @fontsource (see src/theme.ts) -->
<link rel="apple-touch-icon" sizes="120x120" href="<%= require('../../res/vector-icons/120.png') %>">
<link rel="apple-touch-icon" sizes="144x144" href="<%= require('../../res/vector-icons/144.png') %>">
<link rel="apple-touch-icon" sizes="152x152" href="<%= require('../../res/vector-icons/152.png') %>">
@@ -13,8 +14,8 @@
<link rel="icon" type="image/png" sizes="144x144" href="<%= require('../../res/vector-icons/144.png') %>">
<link rel="icon" type="image/png" sizes="512x512" href="<%= require('../../res/vector-icons/512.png') %>">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="apple-mobile-web-app-title" content="Element">
<meta name="application-name" content="Element">
<meta name="apple-mobile-web-app-title" content="Blap">
<meta name="application-name" content="Blap">
<meta name="theme-color" content="#ffffff">
<meta property="og:image" content="<%= og_image_url %>" />
<meta http-equiv="Content-Security-Policy" content="
+249 -198
View File
File diff suppressed because it is too large Load Diff