diff --git a/apps/desktop/blap/build.json b/apps/desktop/blap/build.json new file mode 100644 index 0000000000..1cf51eb3d7 --- /dev/null +++ b/apps/desktop/blap/build.json @@ -0,0 +1,7 @@ +{ + "appId": "lol.utn.blap", + "name": "blap", + "productName": "Blap", + "description": "Blap: a social messaging client", + "protocols": ["blap"] +} diff --git a/apps/desktop/build/icon.ico b/apps/desktop/build/icon.ico index a6349a3282..5d3c920e23 100644 Binary files a/apps/desktop/build/icon.ico and b/apps/desktop/build/icon.ico differ diff --git a/apps/desktop/build/icon.png b/apps/desktop/build/icon.png index ea5fa84819..6c613c98be 100644 Binary files a/apps/desktop/build/icon.png and b/apps/desktop/build/icon.png differ diff --git a/apps/desktop/electron-builder.ts b/apps/desktop/electron-builder.ts index 9bcd771a73..efd5f2272f 100644 --- a/apps/desktop/electron-builder.ts +++ b/apps/desktop/electron-builder.ts @@ -128,8 +128,18 @@ const config: Omit, "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, "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, "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, }, diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 187ecf4726..7586105d9d 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -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", diff --git a/apps/desktop/scripts/release-codeberg.mjs b/apps/desktop/scripts/release-codeberg.mjs new file mode 100644 index 0000000000..b2425bdd15 --- /dev/null +++ b/apps/desktop/scripts/release-codeberg.mjs @@ -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" 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(); diff --git a/apps/desktop/src/electron-main.ts b/apps/desktop/src/electron-main.ts index 372c6fad24..4632a04914 100644 --- a/apps/desktop/src/electron-main.ts +++ b/apps/desktop/src/electron-main.ts @@ -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 diff --git a/apps/desktop/src/ipc.ts b/apps/desktop/src/ipc.ts index c59f70eb82..3aca603949 100644 --- a/apps/desktop/src/ipc.ts +++ b/apps/desktop/src/ipc.ts @@ -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]); diff --git a/apps/desktop/src/updater.ts b/apps/desktop/src/updater.ts index 5b35fc5a09..489ba32f91 100644 --- a/apps/desktop/src/updater.ts +++ b/apps/desktop/src/updater.ts @@ -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 { - 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 { 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 { - 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 { - 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 { + 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); -}); diff --git a/apps/web/package.json b/apps/web/package.json index 2309f10a16..aab6a2a5e7 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -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", diff --git a/apps/web/res/css/_components.pcss b/apps/web/res/css/_components.pcss index 0c2757148f..031a1e8be9 100644 --- a/apps/web/res/css/_components.pcss +++ b/apps/web/res/css/_components.pcss @@ -366,3 +366,4 @@ @import "./views/voip/_LegacyCallViewHeader.pcss"; @import "./views/voip/_LegacyCallViewSidebar.pcss"; @import "./views/voip/_VideoFeed.pcss"; +@import "./yap/_yap.pcss"; diff --git a/apps/web/res/css/yap/_yap.pcss b/apps/web/res/css/yap/_yap.pcss new file mode 100644 index 0000000000..786b127080 --- /dev/null +++ b/apps/web/res/css/yap/_yap.pcss @@ -0,0 +1,1365 @@ +/* + * Yap reskin — base token + foundation overlay. + * Loaded as the top-priority `yap` @layer from the dark-custom theme entry, + * so these win over upstream Element/Compound styles. + * + * Scope: global tokens, fonts, base surfaces, scrollbars. + * Per-surface restyles (rail, room list, etc.) live in their own files and + * consume the --yap-* tokens defined here. + */ +@layer yap { + :root { + /* ---- Typography ---- */ + --yap-font-sans: "Hanken Grotesk", "Inter", system-ui, sans-serif; + --yap-font-mono: "JetBrains Mono", "Courier New", monospace; + + /* Override both legacy + Compound font tokens */ + --font-family: var(--yap-font-sans); + --font-family-monospace: var(--yap-font-mono); + --cpd-font-family-sans: var(--yap-font-sans); + --cpd-font-family-mono: var(--yap-font-mono); + + /* ---- Surfaces (dark only) ---- */ + --yap-surface-titlebar: #101017; + --yap-surface-rail: #101017; + --yap-surface-roomlist: #1a1922; + --yap-surface-main: #201e29; + --yap-surface-userpanel: #131219; + --yap-surface-composer: #2a2735; + --yap-surface-card: rgba(255, 255, 255, 0.025); + --yap-surface-card-hover: rgba(255, 255, 255, 0.06); + --yap-hairline: rgba(255, 255, 255, 0.06); + --yap-divider-heavy: rgba(0, 0, 0, 0.25); + + /* ---- Text ---- */ + --yap-text-primary: #ece9f5; + --yap-text-secondary: #b8b4c6; + --yap-text-muted: #8b8799; + --yap-text-faint: #6b6878; + + /* ---- Accent (themeable) ---- */ + --yap-accent: #7c66e0; + --yap-accent-soft: color-mix(in srgb, var(--yap-accent) 16%, transparent); + + /* ---- Status / semantic ---- */ + --yap-success: #46c08d; + --yap-idle: #e8b04b; + --yap-danger: #e5697a; + + /* ---- Network (bridge) brand colors ---- */ + --yap-net-telegram: #2aabee; + --yap-net-discord: #5b6ee8; + --yap-net-meta: #0a7cff; + + /* ---- Squircle radius scale (consumed per-surface) ---- */ + --yap-radius-space: 15px; + --yap-radius-avatar-chat: 14px; + --yap-radius-avatar-member: 11px; + --yap-radius-avatar-user: 12px; + --yap-radius-avatar-dm: 10px; + --yap-radius-card: 16px; + --yap-radius-row: 8px; + + /* ---- Accent remap: override Compound's green accent → Yap purple ---- + (Was in config.json custom theme; moved here so it loads on the + built-in Dark theme instead of the fragile custom-theme path.) */ + --cpd-color-text-action-accent: var(--yap-accent) !important; + --cpd-color-icon-accent-primary: var(--yap-accent) !important; + --cpd-color-icon-accent-tertiary: #9580ea !important; + /* Compound's token defs are unlayered, so these MUST be !important to win + over them — otherwise primary buttons (Sign in, Continue, Got it, the + E2E verify dialogs) render Element-default white instead of accent. + Note the canonical name is `-hovered`, not `-hover`. */ + --cpd-color-bg-action-primary-rest: var(--yap-accent) !important; + --cpd-color-bg-action-primary-hovered: #8b78e8 !important; + --cpd-color-bg-action-primary-pressed: #6e58d0 !important; + --cpd-color-border-interactive-primary: var(--yap-accent) !important; + + /* Remap the WHOLE Compound green scale → purple. Element's brand accent is + green and legacy `$accent-*` resolve to these tokens, so any leftover + default (new-message bar, jump-to-bottom badge, read marker, links, etc.) + inherits Blap purple from here in one shot. Dark ramp: low = subtle bg, + ~900 = solid accent, high = light text. */ + --cpd-color-green-100: #18151f; + --cpd-color-green-200: #1e1a2b; + --cpd-color-green-300: #271f3d; + --cpd-color-green-400: #322852; + --cpd-color-green-500: #3f3469; + --cpd-color-green-600: #4f4185; + --cpd-color-green-700: #6253a8; + --cpd-color-green-800: #6f5ecb; + --cpd-color-green-900: #7c66e0; + --cpd-color-green-1000: #8b78e8; + --cpd-color-green-1100: #9d8bec; + --cpd-color-green-1200: #b3a5f1; + --cpd-color-green-1300: #ccc1f6; + --cpd-color-green-1400: #e2dbfb; + + /* Squircle ALL Compound avatars (people + rooms) in one shot: + the Avatar component renders `border-radius: var(--cpd-avatar-radius)`. + !important to beat the unlayered default. */ + --cpd-avatar-radius: 30% !important; + + /* ---- Background canvas tokens (the MAIN pane). Without these the + main content area falls back to light. Force the Yap surfaces. ---- */ + --cpd-color-bg-canvas-default: var(--yap-surface-main) !important; + --cpd-color-bg-canvas-disabled: #1a1922 !important; + --cpd-color-bg-subtle-primary: #26242f !important; + --cpd-color-bg-subtle-secondary: var(--yap-surface-roomlist) !important; + } + + body { + font-family: var(--yap-font-sans); + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + } + + /* Mono everywhere timestamps / ids / code render */ + .mx_MessageTimestamp, + code, + kbd, + pre { + font-family: var(--yap-font-mono); + } + + /* ---- Scrollbars (README spec) ---- */ + ::-webkit-scrollbar { + width: 8px; + height: 8px; + } + ::-webkit-scrollbar-thumb { + background: rgba(255, 255, 255, 0.07); + border-radius: 8px; + } + ::-webkit-scrollbar-thumb:hover { + background: rgba(255, 255, 255, 0.14); + } + + /* ============================================================ + Per-surface restyle + (yap is the top @layer, so these win over Element's app-web + layer without specificity fights; !important only where + Element sets values inline.) + ============================================================ */ + + /* ---- Three distinct pane tiers ---- */ + .mx_SpacePanel { + background-color: var(--yap-surface-rail); + } + .mx_LeftPanel, + .mx_RoomListPanel { + background-color: var(--yap-surface-roomlist); + } + /* main column (mirrors Element's own canvas selector) */ + .mx_MatrixChat + > :not(.mx_LeftPanel):not(.mx_SpacePanel):not(.mx_ResizeHandle):not(.mx_LeftPanel_outerWrapper) { + background-color: var(--yap-surface-main); + } + .mx_RoomView, + .mx_HomePage { + background-color: var(--yap-surface-main); + } + + /* ---- Spaces rail ---- */ + .mx_SpacePanel, + .mx_SpacePanel.collapsed { + width: 74px; + padding-block: 10px; + } + .mx_SpacePanel { + /* chunkier top-level space icons (mockup ~46px) */ + --height-topLevel: 44px; + --gutterSize: 0px; + } + /* center the icons in the rail. The selectionWrapper is flex:1 / calc(100%-32px) + (reserving room for the removed label+chevron), so the avatar sits left. + Shrink it to the avatar, then center the button content. */ + .mx_SpacePanel .mx_SpaceItem, + .mx_SpacePanel .mx_SpaceItem > .mx_SpaceButton, + .mx_SpacePanel .mx_SpaceItem.hasSubSpaces > .mx_SpaceButton { + margin-left: 0 !important; + padding-left: 0 !important; + } + .mx_SpacePanel .mx_SpaceButton { + justify-content: center !important; + padding: 4px !important; + } + .mx_SpacePanel .mx_SpaceButton_selectionWrapper { + flex: 0 0 auto !important; + width: auto !important; + margin: 0 !important; + } + /* force centering at every level of the rail (panel > list > item) */ + .mx_SpacePanel, + .mx_SpacePanel > .mx_AutoHideScrollbar, + .mx_SpacePanel .mx_SpaceTreeLevel, + .mx_SpacePanel ul, + .mx_SpacePanel .mx_SpaceItem { + align-items: center !important; + } + .mx_SpacePanel .mx_SpaceItem { + width: 100% !important; + padding-inline: 0 !important; + margin-inline: 0 !important; + justify-content: center !important; + } + /* squircle space icons (Element sets avatar radius inline) */ + .mx_SpacePanel .mx_BaseAvatar { + border-radius: var(--yap-radius-space) !important; + } + /* active space = accent ring, no Discord pill */ + .mx_SpacePanel .mx_SpaceButton_active { + --activeBorder-color: var(--yap-accent); + --activeBackground-color: transparent; + } + /* Clean icon-only rail: kill overflowing name labels, the inline subspace + dropdown chevrons (just open the space in the main view), and the + redundant Settings button (now in the bottom user panel). */ + .mx_SpacePanel .mx_SpaceButton_name, + .mx_SpacePanel .mx_SpaceButton_toggleCollapse, + .mx_SpacePanel_toggleCollapse, + .mx_QuickSettingsButton, + .mx_SpacePanel .mx_ThreadsActivityCentreButton, + .mx_SpacePanel .mx_UserMenu { + display: none !important; + } + /* Threads moved into the bottom user panel — normalise it to a centered + 32px box matching the settings button. */ + .mx_YapUserPanel_threads { + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + } + .mx_YapUserPanel_threads .mx_ThreadsActivityCentreButton_Text, + .mx_YapUserPanel_threads .mx_ThreadsActivityCentreButton_Icon + * { + display: none !important; + } + .mx_YapUserPanel_threads .mx_ThreadsActivityCentreButton, + .mx_YapUserPanel_threads button { + width: 32px !important; + min-width: 32px !important; + height: 32px !important; + padding: 0 !important; + margin: 0 !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + color: var(--yap-text-muted) !important; + } + /* center the icons now that labels are gone */ + .mx_SpacePanel .mx_SpaceButton { + justify-content: center; + } + + /* ---- Space "..." options context menu (mx_SpacePanel_contextMenu) ---- + Opened from the narrow rail it rendered too narrow, ellipsizing labels + ("Home" -> "Ho") and clipping the option tooltips, and it sat on the + default surface. Give it a Blap surface + enough room so nothing clips. */ + .mx_ContextualMenu.mx_SpacePanel_contextMenu, + .mx_SpacePanel_contextMenu { + min-width: 240px; + max-width: 320px; + background-color: var(--yap-surface-composer, var(--cpd-color-bg-canvas-default)); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 14px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); + overflow: visible; /* let option tooltips escape the menu box */ + } + .mx_SpacePanel_contextMenu .mx_IconizedContextMenu_optionList, + .mx_SpacePanel_contextMenu .mx_IconizedContextMenu_optionList > * { + overflow: visible; + } + /* never ellipsize the option labels — there's room for them now */ + .mx_SpacePanel_contextMenu .mx_IconizedContextMenu_label { + overflow: visible; + text-overflow: clip; + white-space: nowrap; + } + .mx_SpacePanel_contextMenu_header { + color: var(--yap-text-primary, inherit); + } + + /* ---- Image lightbox: make the top toolbar + tools clearly visible ---- + The panel was transparent over the dark backdrop, so the zoom/rotate/ + download/close tools nearly vanished. Give it a translucent bar and + brighten the icons with a hover affordance. */ + .mx_ImageView_panel { + background: rgba(20, 18, 26, 0.82); + backdrop-filter: blur(8px); + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + } + .mx_ImageView_button svg { + color: var(--yap-text-primary) !important; + } + .mx_ImageView_button:hover { + background: rgba(255, 255, 255, 0.1); + border-radius: 8px; + } + .mx_ImageView_info_sender, + .mx_ImageView_title { + color: var(--yap-text-primary); + } + + /* ---- Toasts (incl. the "New update" ping) → Blap surface ---- + Was Element-default; match the app surface + rounding. The primary + "Update" button picks up the accent fix automatically. */ + .mx_Toast_toast { + background-color: var(--yap-surface-composer) !important; + border: 1px solid rgba(255, 255, 255, 0.07); + border-radius: 14px; + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.4); + } + .mx_Toast_title h2 { + color: var(--yap-text-primary); + } + .mx_Toast_description { + color: var(--yap-text-secondary); + } + + /* ---- Room list: the LIVE list is shared-components mx_RoomListItemView, + NOT the legacy mx_RoomTile. Target the real class. ---- */ + .mx_RoomListItemView .mx_BaseAvatar { + border-radius: var(--yap-radius-avatar-dm) !important; + } + /* ---- Yap channel-row room list (replaces Element's filter-list) ---- */ + .mx_YapRoomList { + display: flex; + flex-direction: column; + gap: 1px; + padding: 6px; + overflow-y: auto; + flex: 1 1 0; + min-height: 0; + } + .mx_YapRoomRow { + display: flex; + align-items: center; + gap: 8px; + height: 34px; + padding: 0 8px; + border-radius: var(--yap-radius-row); + color: var(--yap-text-muted); + cursor: pointer; + font-size: 15px; + font-weight: 500; + user-select: none; + } + .mx_YapRoomRow:hover { + background-color: rgba(255, 255, 255, 0.04); + color: var(--yap-text-secondary); + } + .mx_YapRoomRow_unread { + color: var(--yap-text-primary); + } + .mx_YapRoomRow_unread .mx_YapRoomRow_name { + font-weight: 600; + color: var(--yap-text-primary); + } + .mx_YapRoomRow_active, + .mx_YapRoomRow_active:hover { + background-color: var(--yap-accent-soft); + box-shadow: inset 3px 0 0 var(--yap-accent); + color: var(--yap-text-primary); + } + .mx_YapRoomRow_active .mx_YapRoomRow_name { + color: var(--yap-text-primary); + font-weight: 600; + } + .mx_YapRoomRow_glyph { + font-size: 20px; + color: var(--yap-text-faint); + flex-shrink: 0; + width: 20px; + text-align: center; + } + .mx_YapRoomRow_glyphSvg { + flex-shrink: 0; + color: var(--yap-text-faint); + } + .mx_YapRoomRow_name { + flex: 1 1 0; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mx_YapRoomRow_dm .mx_BaseAvatar { + flex-shrink: 0; + } + .mx_YapRoomRow_badge { + flex-shrink: 0; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background-color: var(--yap-text-faint); + color: var(--yap-surface-rail); + font-size: 11px; + font-weight: 700; + font-variant-numeric: tabular-nums; + display: flex; + align-items: center; + justify-content: center; + } + .mx_YapRoomRow_badge_mention { + background-color: var(--yap-danger); + color: #fff; + } + /* bridge-platform badge (which network a DM is from) */ + .mx_YapPlatformBadge { + flex-shrink: 0; + padding: 1px 5px; + border-radius: 6px; + font-size: 9.5px; + font-weight: 800; + letter-spacing: 0.03em; + line-height: 1.5; + color: #fff; + text-shadow: 0 1px 1px rgba(0, 0, 0, 0.25); + } + /* invite pill */ + .mx_YapRoomRow_inviteBadge { + margin-left: auto; + flex-shrink: 0; + padding: 1px 7px; + border-radius: 8px; + background-color: var(--yap-accent); + color: #fff; + font-size: 10.5px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.04em; + } + /* draggable room rows */ + .mx_YapRoomRow[draggable="true"] { + cursor: pointer; + } + .mx_YapRoomRow[draggable="true"]:active { + cursor: grabbing; + } + /* drop-line indicator: shows where the dragged room will land */ + .mx_YapRoomRow_dropBefore { + position: relative; + } + .mx_YapRoomRow_dropAfter { + position: relative; + } + .mx_YapRoomRow_dropBefore::before, + .mx_YapRoomRow_dropAfter::after { + content: ""; + position: absolute; + left: 8px; + right: 8px; + height: 2px; + border-radius: 1px; + background-color: var(--yap-accent); + box-shadow: 0 0 4px var(--yap-accent); + pointer-events: none; + } + .mx_YapRoomRow_dropBefore::before { + top: -2px; + } + .mx_YapRoomRow_dropAfter::after { + bottom: -2px; + } + .mx_YapBrowseRooms { + display: flex; + align-items: center; + gap: 8px; + height: 36px; + margin: 2px 0 4px; + padding: 0 8px; + border-radius: var(--yap-radius-row); + color: var(--yap-accent); + font-size: 14px; + font-weight: 600; + cursor: pointer; + } + .mx_YapBrowseRooms:hover { + background-color: rgba(255, 255, 255, 0.04); + } + /* "N new rooms since you last looked" count badge */ + .mx_YapBrowseRooms_count { + margin-left: auto; + flex-shrink: 0; + min-width: 18px; + height: 18px; + padding: 0 5px; + border-radius: 9px; + background-color: var(--yap-accent); + color: #fff; + font-size: 11px; + font-weight: 700; + font-variant-numeric: tabular-nums; + display: flex; + align-items: center; + justify-content: center; + } + .mx_YapRoomList_header { + padding: 14px 10px 4px; + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--yap-text-faint); + } + /* in-call members shown under a voice channel (who's in before you join) */ + .mx_YapVoiceMembers { + display: flex; + flex-direction: column; + gap: 1px; + padding: 2px 0 4px 18px; + } + .mx_YapVoiceMember { + display: flex; + align-items: center; + gap: 8px; + height: 28px; + padding: 0 8px; + border-radius: var(--yap-radius-row); + color: var(--yap-text-secondary); + font-size: 14px; + } + .mx_YapVoiceMember:hover { + background-color: rgba(255, 255, 255, 0.04); + } + .mx_YapVoiceMember_name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + + /* ---- Bottom user panel + voice-connected strip ---- */ + .mx_YapUserPanel { + margin-top: auto; + flex-shrink: 0; + background-color: var(--yap-surface-userpanel); + border-top: 1px solid var(--yap-hairline); + } + .mx_YapVoiceConnected { + display: flex; + align-items: center; + gap: 8px; + margin: 8px; + padding: 8px 10px; + border-radius: 10px; + background-color: color-mix(in srgb, var(--yap-success) 7%, transparent); + border: 1px solid color-mix(in srgb, var(--yap-success) 18%, transparent); + cursor: pointer; + } + .mx_YapVoiceConnected_icon { + color: var(--yap-success); + flex-shrink: 0; + } + .mx_YapVoiceConnected_text { + display: flex; + flex-direction: column; + min-width: 0; + } + .mx_YapVoiceConnected_label { + color: var(--yap-success); + font-size: 12.5px; + font-weight: 700; + } + .mx_YapVoiceConnected_room { + color: var(--yap-text-muted); + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mx_YapUserPanel_row { + display: flex; + align-items: center; + gap: 8px; + height: 56px; + padding: 0 10px; + } + .mx_YapUserPanel_info { + display: flex; + flex-direction: column; + min-width: 0; + flex: 1 1 0; + } + .mx_YapUserPanel_name { + color: var(--yap-text-primary); + font-size: 14px; + font-weight: 700; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mx_YapUserPanel_id { + color: var(--yap-text-faint); + font-size: 11.5px; + font-family: var(--yap-font-mono); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .mx_YapUserPanel_btn { + flex-shrink: 0; + width: 32px; + height: 32px; + display: flex; + align-items: center; + justify-content: center; + border: none; + background: transparent; + color: var(--yap-text-muted); + border-radius: 8px; + cursor: pointer; + } + .mx_YapUserPanel_btn:hover { + background-color: rgba(255, 255, 255, 0.06); + color: var(--yap-text-primary); + } + + .mx_RoomListItemView { + border-radius: var(--yap-radius-row); + margin-inline: 6px; + } + .mx_RoomListItemView:hover { + background-color: rgba(255, 255, 255, 0.04); + } + .mx_RoomListItemView_selected { + background-color: var(--yap-accent-soft) !important; + box-shadow: inset 3px 0 0 var(--yap-accent); + } + /* legacy list (kept harmless in case it ever renders) */ + .mx_RoomTile_avatarContainer .mx_BaseAvatar { + border-radius: var(--yap-radius-avatar-dm) !important; + } + .mx_RoomTile_selected { + background-color: var(--yap-accent-soft); + box-shadow: inset 3px 0 0 var(--yap-accent); + } + + /* ============================================================ + Room view (header / timeline / composer) + ============================================================ */ + + /* ---- Room header ---- */ + .mx_RoomHeader { + height: 50px; + background-color: var(--yap-surface-main); + border-bottom: 1px solid var(--yap-divider-heavy); + } + + /* ---- Timeline surface ---- */ + .mx_RoomView_messagePanel, + .mx_RoomView_messageListWrapper { + background-color: var(--yap-surface-main); + } + + /* squircle chat avatars (Element sets radius inline) */ + .mx_EventTile_avatar .mx_BaseAvatar { + border-radius: var(--yap-radius-avatar-chat) !important; + } + + /* sender name weight */ + .mx_DisambiguatedProfile_displayName { + font-weight: 700; + } + + /* image-spoiler upload checkbox */ + .mx_UploadConfirmDialog_spoiler { + display: flex; + align-items: center; + gap: 8px; + margin: 14px 0 2px; + color: var(--yap-text-secondary); + font-size: 14px; + cursor: pointer; + } + + /* message links in accent */ + .mx_EventTile_body a, + .mx_EventTile_content a { + color: var(--yap-accent); + text-decoration: none; + } + .mx_EventTile_body a:hover, + .mx_EventTile_content a:hover { + text-decoration: underline; + } + /* subtle per-message hover */ + .mx_EventTile:not([data-layout="bubble"]):hover .mx_EventTile_line { + background-color: rgba(255, 255, 255, 0.02); + border-radius: 6px; + } + /* comfortable message vertical rhythm */ + .mx_EventTile { + padding-top: 6px; + } + + /* ---- Discord-style sender row: name + timestamp inline ---- + In group layout Element renders the timestamp absolutely at the left + of the message line, which lands it under the avatar on the first + message of a group. We render an inline copy next to the sender name + (see EventTile.tsx mx_EventTile_senderRow) and hide the gutter one on + first-in-group tiles. Continuation lines keep the gutter timestamp. */ + .mx_EventTile_senderRow { + display: flex; + align-items: baseline; + gap: 7px; + margin-left: 64px; /* $left-gutter */ + max-width: calc(100% - 64px); + } + .mx_EventTile_senderRow .mx_DisambiguatedProfile { + margin-left: 0; + min-width: 0; + } + .mx_EventTile_senderRow .mx_MessageTimestamp { + position: static; + text-align: left; + opacity: 0.5; + flex: 0 0 auto; + font-size: 11px; + } + /* hide the gutter timestamp on the first message of a group (now inline) */ + .mx_EventTile[data-layout="group"]:not(.mx_EventTile_continuation) > .mx_EventTile_line > .mx_MessageTimestamp { + display: none; + } + + /* ---- Composer pill ---- */ + .mx_MessageComposer { + background-color: var(--yap-surface-main); + } + .mx_MessageComposer_wrapper { + background-color: var(--yap-surface-composer); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 13px; + margin: 0 16px 6px; + padding: 2px 10px; + } + .mx_MessageComposer_input { + background-color: transparent; + border: none; + padding: 4px 4px; + } + .mx_MessageComposer_input textarea { + background-color: transparent !important; + } + /* remove the left-gutter e2e-status + avatar icons that overlapped the + placeholder (encryption status is in the room header; mockup composer + has no left icon) and reclaim their padding. */ + .mx_MessageComposer_e2eIconWrapper, + .mx_MessageComposer .mx_MessageComposer_avatar { + display: none !important; + } + .mx_MessageComposer { + padding-left: 8px !important; + } + + /* ============================================================ + Call presence — in-call avatar stack on room rows (headline feature) + ============================================================ */ + .mx_YapCallFacepile { + display: flex; + align-items: center; + gap: 4px; + margin-inline-start: auto; /* push to trailing edge of the row */ + padding-inline: 4px; + flex-shrink: 0; + } + /* squircle peek avatars with a list-colored border, slight overlap */ + .mx_YapCallFacepile .mx_BaseAvatar { + border-radius: 7px !important; + box-shadow: 0 0 0 2px var(--yap-surface-roomlist); + } + .mx_YapCallFacepile_more { + font: var(--cpd-font-body-xs-medium, 11px/1 var(--yap-font-mono)); + font-family: var(--yap-font-mono); + color: var(--yap-accent); + font-weight: 700; + } + + /* ============================================================ + Right panel — Members + ============================================================ */ + .mx_MemberListView, + .mx_MemberList { + background-color: var(--yap-surface-roomlist); + } + /* squircle member avatars */ + .mx_MemberTileView_avatar .mx_BaseAvatar { + border-radius: var(--yap-radius-avatar-member) !important; + } + .mx_MemberTileView_name { + font-weight: 600; + } + /* role / section separators (OWNER · ONLINE · OFFLINE) */ + .mx_MemberListView_separator { + color: var(--yap-text-muted); + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + } + /* presence dot colors */ + .mx_PresenceLabel_online { + color: var(--yap-success); + } + .mx_PresenceLabel_unavailable { + color: var(--yap-idle); + } + + /* ============================================================ + Reactions — pills + ============================================================ */ + .mx_ReactionsRowButton { + background-color: var(--yap-accent-soft); + border: 1px solid color-mix(in srgb, var(--yap-accent) 30%, transparent); + border-radius: 999px; + } + .mx_ReactionsRowButton_count { + color: var(--yap-accent); + font-weight: 700; + font-variant-numeric: tabular-nums; + } + + /* ============================================================ + Thread summary — chip + ============================================================ */ + .mx_ThreadSummary { + background-color: rgba(255, 255, 255, 0.035); + border-radius: 12px; + } + .mx_ThreadSummary_replyCount, + .mx_ThreadSummary .mx_ThreadSummary_content { + color: var(--yap-accent); + } + + /* ============================================================ + Room intro block + ============================================================ */ + .mx_NewRoomIntro { + color: var(--yap-text-secondary); + } + + /* ============================================================ + Notification badges (rail + room list, consistent) + mention = red, unread dot = accent + ============================================================ */ + .mx_NotificationBadge_level_highlight { + background-color: var(--yap-danger); + } + .mx_NotificationBadge_dot { + background-color: var(--yap-accent); + } + + /* ============================================================ + Space landing (the space "directory" head start) — #5 + ============================================================ */ + .mx_SpaceRoomView_landing_name { + font-size: 30px; + font-weight: 800; + color: var(--yap-text-primary); + } + .mx_SpaceRoomView_landing_topic { + color: var(--yap-text-muted); + } + /* "this is a space, not a server" explainer callout (mockup) */ + .mx_YapSpaceExplainer { + margin: 18px 0 6px; + padding: 11px 14px; + border-radius: 12px; + background-color: color-mix(in srgb, var(--yap-accent) 9%, transparent); + border: 1px solid color-mix(in srgb, var(--yap-accent) 22%, transparent); + color: var(--yap-text-secondary); + font-size: 13.5px; + line-height: 1.5; + } + .mx_YapSpaceExplainer strong { + color: var(--yap-text-primary); + font-weight: 700; + } + + /* ============================================================ + Room header — icon cluster + header facepile + ============================================================ */ + .mx_RoomHeader_icon { + color: var(--yap-text-muted); + } + .mx_RoomHeader_heading { + font-weight: 700; + color: var(--yap-text-primary); + } + /* # glyph + topic line in the header (mockup) */ + .mx_RoomHeader_info { + display: flex !important; + align-items: center; + min-width: 0; + } + .mx_YapHeader_hash { + color: var(--yap-text-faint); + font-weight: 700; + margin-right: 3px; + } + .mx_YapHeader_topic { + margin-left: 12px; + padding-left: 12px; + border-left: 1px solid var(--yap-hairline); + color: var(--yap-text-muted); + font-size: 13.5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 1 1 auto; + min-width: 0; + } + .mx_RoomHeader .mx_FacePile .mx_BaseAvatar { + border-radius: 7px !important; + } + /* make the call control read as the mockup's accent "Call" affordance */ + .mx_RoomHeader_videoCallOption { + color: var(--yap-accent); + } + + /* ============================================================ + Polish — selection bar, composer buttons, rail add button + ============================================================ */ + /* active room: inset accent left-bar (mockup) */ + .mx_RoomTile_selected { + box-shadow: inset 3px 0 0 var(--yap-accent); + } + /* composer: send in accent, other actions muted */ + .mx_MessageComposer_sendMessage { + color: var(--yap-accent); + } + .mx_MessageComposer_button { + color: var(--yap-text-muted); + } + /* spaces rail: "add" utility reads green (mockup) */ + .mx_SpaceButton_new .mx_SpaceButton_icon { + color: var(--yap-success); + } + + /* ============================================================ + Space Directory — browse & join card grid (#5) + Restyle of SpaceHierarchy's list into the mockup card grid. + (Element shows joined + unjoined in one list with View/Join + buttons; a two-section split would need React.) + ============================================================ */ + .mx_SpaceHierarchy_list { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(282px, 1fr)); + gap: 14px; + list-style: none; + padding: 0; + } + .mx_SpaceHierarchy_roomTileWrapper { + margin: 0; + } + .mx_SpaceHierarchy_roomTile { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 10px; + background-color: var(--yap-surface-card); + border: 1px solid var(--yap-hairline); + border-radius: var(--yap-radius-card); + padding: 16px; + } + .mx_SpaceHierarchy_roomTile:hover { + background-color: var(--yap-surface-card-hover); + } + .mx_SpaceHierarchy_roomTile_avatar .mx_BaseAvatar { + border-radius: 13px !important; + } + .mx_SpaceHierarchy_roomTile_name { + font-weight: 700; + color: var(--yap-text-primary); + } + .mx_SpaceHierarchy_listHeader { + color: var(--yap-text-muted); + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + } + /* join-state section headers (Your rooms / haven't joined / Spaces) span the grid */ + .mx_YapHierarchy_sectionHeader { + grid-column: 1 / -1; + margin: 16px 0 2px; + font-size: 11.5px; + font-weight: 700; + letter-spacing: 0.05em; + text-transform: uppercase; + color: var(--yap-text-muted); + } + /* hide the admin "manage suggested rooms" clutter (checkboxes + ADD/REMOVE/ + MARK buttons) — not in the mockup; cards are clean + clickable. + The checkbox is a Compound
/InlineField, not .mx_Checkbox. */ + .mx_SpaceHierarchy_listHeader_buttons, + .mx_SpaceHierarchy_actions form, + .mx_SpaceHierarchy_actions [class*="_inline-field"], + .mx_SpaceHierarchy_actions input[type="checkbox"] { + display: none !important; + } + /* show the Open/Join button always (Element hides it until hover), bottom-right */ + .mx_SpaceHierarchy_roomTile .mx_SpaceHierarchy_actions { + margin: 8px 0 0 auto; + align-self: flex-end; + } + .mx_SpaceHierarchy_roomTile .mx_SpaceHierarchy_actions .mx_AccessibleButton { + visibility: visible !important; + border-radius: 9px; + font-weight: 700; + } + .mx_SpaceHierarchy_roomTile .mx_AccessibleButton_kind_primary { + background-color: var(--yap-accent) !important; + } + + /* ============================================================ + Bridges panel (#6) — static bridge cards in the right panel + ============================================================ */ + .mx_YapBridgesView { + background-color: var(--yap-surface-roomlist); + } + .mx_YapBridgesView_list { + display: flex; + flex-direction: column; + gap: 10px; + padding: 12px; + } + .mx_YapBridgesView_empty { + color: var(--yap-text-muted); + font-size: 13.5px; + padding: 8px 4px; + } + + /* ---- Pinned messages (banner + right-panel card + tiles) ---- */ + .mx_PinnedMessageBanner { + background-color: var(--yap-surface-main); + border-bottom: 1px solid var(--yap-hairline); + box-shadow: none; + } + .mx_PinnedMessageBanner_main { + border-radius: 8px; + } + .mx_PinnedMessageBanner_main:hover { + background-color: var(--yap-surface-card); + } + .mx_PinnedMessageBanner_title { + color: var(--yap-accent) !important; + } + .mx_PinnedMessageBanner_PinIcon { + fill: var(--yap-text-muted) !important; + } + .mx_PinnedMessageBanner_Indicator { + background-color: var(--yap-hairline) !important; + } + .mx_PinnedMessageBanner_Indicator--active { + background-color: var(--yap-accent) !important; + } + + /* right-panel card */ + .mx_PinnedMessagesCard .mx_PinnedMessagesCard_Separator hr, + .mx_PinnedMessagesCard .mx_Separator { + border-color: var(--yap-hairline); + } + .mx_PinnedMessagesCard_unpin { + background: var(--yap-surface-roomlist); + box-shadow: none; + border-top: 1px solid var(--yap-hairline); + } + + /* each pinned message rendered as a soft card */ + .mx_PinnedEventTile { + padding: 10px 12px; + border-radius: 12px; + background-color: var(--yap-surface-card); + border: 1px solid var(--yap-hairline); + transition: background-color 0.12s ease; + } + .mx_PinnedEventTile:hover { + background-color: var(--yap-surface-card-hover); + } + .mx_PinnedEventTile_sender { + color: var(--yap-text-primary); + } + .mx_PinnedEventTile_thread span { + color: var(--yap-text-muted) !important; + } + .mx_PinnedEventTile_thread svg { + fill: var(--yap-text-faint) !important; + } + .mx_PinnedEventTile_thread button, + .mx_PinnedEventTile .mx_EventTile_body a { + color: var(--yap-accent); + } + + /* ---- Right panel: room info card ---- */ + .mx_RoomSummaryCard_roomName { + color: var(--yap-text-primary); + font-weight: 700; + } + .mx_RoomSummaryCard_alias { + color: var(--yap-text-muted); + } + .mx_RoomSummaryCard_topic, + .mx_RoomSummaryCard_topic_box { + color: var(--yap-text-secondary); + } + /* info pills (encryption state / history visibility) themed off Element blue/green */ + .mx_RoomSummaryCard_badges [data-kind] { + background-color: var(--yap-surface-card) !important; + border: 1px solid var(--yap-hairline) !important; + color: var(--yap-text-secondary) !important; + box-shadow: none !important; + } + .mx_RoomSummaryCard_badges [data-kind] svg { + color: currentColor !important; + fill: currentColor !important; + } + .mx_RoomSummaryCard_badges [data-kind="green"] { + color: #5ed1a0 !important; /* encrypted = positive */ + border-color: color-mix(in srgb, #5ed1a0 32%, transparent) !important; + } + /* separators + menu rows */ + .mx_RoomSummaryCard hr, + .mx_RoomSummaryCard [role="separator"] { + border-color: var(--yap-hairline) !important; + background-color: var(--yap-hairline) !important; + } + .mx_RoomSummaryCard [role="menuitem"]:hover, + .mx_RoomSummaryCard [role="menuitemcheckbox"]:hover { + background-color: var(--yap-surface-card) !important; + border-radius: 8px; + } + /* accent the edit/links + the search field */ + .mx_RoomSummaryCard_topic_edit, + .mx_RoomSummaryCard a { + color: var(--yap-accent); + } + .mx_RoomSummaryCard_search { + background-color: var(--yap-surface-card); + border: 1px solid var(--yap-hairline); + border-radius: 10px; + } + + /* ---- Polls (timeline poll body + options) ---- */ + .mx_MPollBody_totalVotes { + color: var(--yap-text-muted); + } + .mx_PollOption { + border: 1px solid var(--yap-hairline) !important; + background-color: var(--yap-surface-card) !important; + border-radius: 10px !important; + } + .mx_PollOption.mx_PollOption_live-option:hover { + background-color: var(--yap-surface-card-hover) !important; + border-color: var(--yap-accent) !important; + } + /* selected option gets an accent outline */ + .mx_PollOption_checked { + border-color: var(--yap-accent) !important; + } + /* popularity bar: track + accent fill */ + .mx_PollOption_popularityBackground { + background-color: var(--yap-hairline) !important; + } + .mx_PollOption_popularityAmount { + background-color: var(--yap-accent) !important; + } + .mx_PollOption_checked .mx_PollOption_popularityBackground .mx_PollOption_popularityAmount, + .mx_PollOption_ended.mx_PollOption_checked .mx_PollOption_popularityBackground .mx_PollOption_popularityAmount { + background-color: var(--yap-accent) !important; + } + /* checked radio → accent fill + checkmark */ + .mx_PollOption_checked .mx_StyledRadioButton_checked input[type="radio"]:checked + div, + .mx_PollOption_ended.mx_PollOption_checked .mx_StyledRadioButton_checked input[type="radio"]:checked + div { + border-color: var(--yap-accent) !important; + background-color: var(--yap-accent) !important; + } + .mx_PollOption_optionVoteCount { + color: var(--yap-text-secondary); + } + + /* ---- Poll create dialog ---- */ + .mx_PollCreateDialog_option { + background-color: var(--yap-surface-card); + border: 1px solid var(--yap-hairline); + border-radius: 10px; + } + .mx_PollCreateDialog_addOption { + color: var(--yap-accent); + } + + /* ---- Settings menus (dialog: tabs, sections, fields) ---- */ + /* left tab list: active tab → accent */ + .mx_TabbedView_tabLabel_active { + background-color: var(--yap-accent-soft) !important; + color: var(--yap-accent) !important; + } + .mx_TabbedView_tabLabel_active .mx_TabbedView_tabLabel_text { + color: var(--yap-accent) !important; + } + .mx_TabbedView_tabLabel:hover { + background-color: var(--yap-surface-card) !important; + } + .mx_UserSettingsDialog_title_strong, + .mx_SettingsSection_header, + .mx_SettingsSubsectionHeading_heading { + color: var(--yap-text-primary); + } + .mx_SettingsSubsection_description, + .mx_SettingsTab_subsectionText, + .mx_SettingsFieldset_description, + .mx_SettingsTab_section_caption { + color: var(--yap-text-muted); + } + /* dividers between sections */ + .mx_SettingsTab .mx_SettingsSubsection + .mx_SettingsSubsection, + .mx_SettingsSection + .mx_SettingsSection { + border-color: var(--yap-hairline); + } + /* form fields + dropdowns in settings → Yap surfaces */ + .mx_SettingsTab .mx_Field, + .mx_SettingsTab .mx_Dropdown, + .mx_SettingsTab select, + .mx_SettingsTab input[type="text"], + .mx_SettingsTab input[type="password"] { + background-color: var(--yap-surface-card); + border-color: var(--yap-hairline); + } + .mx_SettingsTab .mx_Field:focus-within { + border-color: var(--yap-accent); + } + + /* toggle switches → accent when on (app-wide) */ + .mx_ToggleSwitch_on, + .mx_ToggleSwitch.mx_ToggleSwitch_on { + background-color: var(--yap-accent) !important; + } + + /* ---- leftover Element-green defaults → Blap accent ---- */ + /* "new messages" bar above the timeline + jump-to-bottom */ + .mx_TopUnreadMessagesBar_scrollUp, + .mx_TopUnreadMessagesBar_markAsRead { + color: var(--yap-accent) !important; + } + .mx_JumpToBottomButton_highlight .mx_JumpToBottomButton_badge { + background-color: var(--yap-accent) !important; + } + /* read-marker line ("new messages" divider) */ + .mx_RoomView_myReadMarker { + background-color: var(--yap-accent) !important; + border-top-color: var(--yap-accent) !important; + } + /* one-off hardcoded brand green */ + .mx_AnalyticsLearnMoreDialog a { + color: var(--yap-accent) !important; + } + + /* ---- Login / auth screen (Blap branded) ---- + Target = the blap mockup: one centered card on the molecule background, + NOT Element's two-pane (left logo / right form) layout. */ + .mx_AuthPage { + background-color: var(--yap-surface-rail) !important; + background-image: + linear-gradient(180deg, rgba(15, 13, 20, 0.5), rgba(15, 13, 20, 0.8)), + url("../../../img/blap-login-bg.png") !important; + background-size: cover !important; + background-position: center !important; + background-repeat: no-repeat !important; + align-items: center !important; + justify-content: center !important; + } + /* collapse the two-pane modal into a single transparent wrapper */ + .mx_AuthPage_modal { + margin: auto !important; + background-color: transparent !important; + backdrop-filter: none; + border: none !important; + box-shadow: none !important; + border-radius: 20px !important; + } + .mx_AuthPage_modalContent { + flex-direction: column !important; + background-color: transparent !important; + } + /* hide the left logo / language pane — the card carries its own brand */ + .mx_AuthHeader { + display: none !important; + } + /* the card itself */ + .mx_AuthBody { + width: 384px !important; + box-sizing: border-box; + background-color: var(--yap-surface-main) !important; + border: 1px solid rgba(255, 255, 255, 0.07) !important; + border-radius: 20px !important; + box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55) !important; + padding: 30px 30px 24px !important; + color: var(--yap-text-secondary) !important; + } + /* branded welcome header (rendered by Login.tsx) */ + .mx_AuthBody_blapWelcome { + display: flex; + flex-direction: column; + align-items: center; + text-align: center; + gap: 4px; + margin-bottom: 22px; + } + .mx_AuthBody_blapWelcome_mark { + width: 58px; + height: 58px; + border-radius: 17px; + background: linear-gradient(135deg, #7c66e0, #b16ae0); + padding: 11px; + object-fit: contain; + box-shadow: 0 8px 24px rgba(124, 102, 224, 0.4); + margin-bottom: 8px; + } + .mx_AuthBody .mx_AuthBody_blapWelcome_title { + margin: 0 !important; + font-size: 27px !important; + font-weight: 800 !important; + color: var(--yap-text-primary) !important; + } + .mx_AuthBody_blapWelcome_tagline { + color: var(--yap-text-muted); + font-size: 14px; + } + /* server picker → boxed homeserver field */ + .mx_AuthBody .mx_ServerPicker { + background-color: rgba(0, 0, 0, 0.22); + border: 1px solid rgba(255, 255, 255, 0.05); + border-radius: 11px; + padding: 9px 14px; + margin: 0 0 16px !important; + align-items: center; + } + .mx_AuthBody .mx_ServerPicker_change { + color: var(--yap-accent); + } + /* gradient sign-in button */ + .mx_AuthBody .mx_Login_submit { + width: 100%; + margin-top: 6px; + background: linear-gradient(135deg, #7c66e0, #b16ae0) !important; + border: none !important; + border-radius: 12px !important; + padding: 11px !important; + font-weight: 700 !important; + color: #fff !important; + } + .mx_AuthBody .mx_Login_submit:hover { + filter: brightness(1.07); + } + .mx_AuthBody .mx_SSOButtons { + margin-top: 14px; + } + .mx_AuthBody, + .mx_AuthFooter { + color: var(--yap-text-secondary); + } + .mx_AuthBody a, + .mx_AuthFooter a { + color: var(--yap-accent); + } +} diff --git a/apps/web/res/img/blap-login-bg.png b/apps/web/res/img/blap-login-bg.png new file mode 100644 index 0000000000..253e68acf1 Binary files /dev/null and b/apps/web/res/img/blap-login-bg.png differ diff --git a/apps/web/res/img/element-desktop-logo.svg b/apps/web/res/img/element-desktop-logo.svg index 2483c75f98..a4e6d1b760 100644 --- a/apps/web/res/img/element-desktop-logo.svg +++ b/apps/web/res/img/element-desktop-logo.svg @@ -1,87 +1 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + \ No newline at end of file diff --git a/apps/web/res/manifest.json b/apps/web/res/manifest.json index ff9b8edf30..3ea2a1b0c4 100644 --- a/apps/web/res/manifest.json +++ b/apps/web/res/manifest.json @@ -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": [ { diff --git a/apps/web/res/themes/dark/css/dark.pcss b/apps/web/res/themes/dark/css/dark.pcss index abb4fc04ee..cf1adc30bd 100644 --- a/apps/web/res/themes/dark/css/dark.pcss +++ b/apps/web/res/themes/dark/css/dark.pcss @@ -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); diff --git a/apps/web/res/themes/element/img/logos/element-logo.svg b/apps/web/res/themes/element/img/logos/element-logo.svg index 54a91b72f8..a2fa990cc1 100644 --- a/apps/web/res/themes/element/img/logos/element-logo.svg +++ b/apps/web/res/themes/element/img/logos/element-logo.svg @@ -1,7 +1 @@ - - - - - - - + \ No newline at end of file diff --git a/apps/web/res/vector-icons/1024.png b/apps/web/res/vector-icons/1024.png index 77d67480eb..22988d7405 100644 Binary files a/apps/web/res/vector-icons/1024.png and b/apps/web/res/vector-icons/1024.png differ diff --git a/apps/web/res/vector-icons/120.png b/apps/web/res/vector-icons/120.png index bf604618f4..9094efedd4 100644 Binary files a/apps/web/res/vector-icons/120.png and b/apps/web/res/vector-icons/120.png differ diff --git a/apps/web/res/vector-icons/144.png b/apps/web/res/vector-icons/144.png index 5f09750bc8..b7ce20cc19 100644 Binary files a/apps/web/res/vector-icons/144.png and b/apps/web/res/vector-icons/144.png differ diff --git a/apps/web/res/vector-icons/152.png b/apps/web/res/vector-icons/152.png index 2948c2e9b0..695772690b 100644 Binary files a/apps/web/res/vector-icons/152.png and b/apps/web/res/vector-icons/152.png differ diff --git a/apps/web/res/vector-icons/180.png b/apps/web/res/vector-icons/180.png index 8a412c5604..e52dca4d63 100644 Binary files a/apps/web/res/vector-icons/180.png and b/apps/web/res/vector-icons/180.png differ diff --git a/apps/web/res/vector-icons/24.png b/apps/web/res/vector-icons/24.png index 3aa66a3b1c..ad82ac163c 100644 Binary files a/apps/web/res/vector-icons/24.png and b/apps/web/res/vector-icons/24.png differ diff --git a/apps/web/res/vector-icons/512.png b/apps/web/res/vector-icons/512.png index 337a183e30..6c613c98be 100644 Binary files a/apps/web/res/vector-icons/512.png and b/apps/web/res/vector-icons/512.png differ diff --git a/apps/web/src/ContentMessages.ts b/apps/web/src/ContentMessages.ts index 15a43d79b6..32be8b7fea 100644 --- a/apps/web/src/ContentMessages.ts +++ b/apps/web/src/ContentMessages.ts @@ -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, + spoiler?: boolean, ): Promise { const fileName = file.name || _t("common|attachment"); const content: Omit & { info: Partial } = { @@ -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)["m.yap.spoiler"] = true; + } // Attach mentions, which really only applies if there's a replyToEvent. attachMentions(matrixClient.getSafeUserId(), content, null, replyToEvent); diff --git a/apps/web/src/components/structures/RightPanel.tsx b/apps/web/src/components/structures/RightPanel.tsx index 24c1655455..0e5eb49c87 100644 --- a/apps/web/src/components/structures/RightPanel.tsx +++ b/apps/web/src/components/structures/RightPanel.tsx @@ -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 { } break; + case RightPanelPhases.Bridges: + if (this.props.room) { + card = ; + } + break; + case RightPanelPhases.MemberInfo: case RightPanelPhases.EncryptionPanel: { if (!!cardState?.member) { diff --git a/apps/web/src/components/structures/SpaceHierarchy.tsx b/apps/web/src/components/structures/SpaceHierarchy.tsx index c1d5976869..35b383a188 100644 --- a/apps/web/src/components/structures/SpaceHierarchy.tsx +++ b/apps/web/src/components/structures/SpaceHierarchy.tsx @@ -535,55 +535,70 @@ export const HierarchyLevel: React.FC = ({ ); 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 => ( + 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 => ( + { + 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} + > + + + ); + return ( - {uniqBy(sortedChildren, "room_id").map((room) => { - if (room.room_type !== RoomType.Space) { - return ( - 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 ( - { - 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} - > - - - ); - } - })} + {unjoinedRooms.length > 0 && ( +

Rooms you haven't joined

+ )} + {unjoinedRooms.map(renderRoomTile)} + {joinedRooms.length > 0 &&

Your rooms

} + {joinedRooms.map(renderRoomTile)} + {subspaces.length > 0 &&

Spaces

} + {subspaces.map(renderSpaceTile)}
); }; diff --git a/apps/web/src/components/structures/SpaceRoomView.tsx b/apps/web/src/components/structures/SpaceRoomView.tsx index 9bb04ce552..5f66a7c44a 100644 --- a/apps/web/src/components/structures/SpaceRoomView.tsx +++ b/apps/web/src/components/structures/SpaceRoomView.tsx @@ -292,6 +292,12 @@ const SpaceLanding: React.FC<{ space: Room }> = ({ space }) => { +
+ This is a space, 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. +
+ ); diff --git a/apps/web/src/components/structures/auth/Login.tsx b/apps/web/src/components/structures/auth/Login.tsx index dca3b30ee0..9160c45e60 100644 --- a/apps/web/src/components/structures/auth/Login.tsx +++ b/apps/web/src/components/structures/auth/Login.tsx @@ -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 { -

- {_t("action|sign_in")} - {loader} -

+
+ +

+ {`Welcome to ${SdkConfig.get("brand")}`} + {loader} +

+
+ {"Cozy chat & calls for you and your people."} +
+
{errorTextSection} {serverDeadSection} 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, diff --git a/apps/web/src/components/views/dialogs/UploadConfirmDialog.tsx b/apps/web/src/components/views/dialogs/UploadConfirmDialog.tsx index 820b047c38..3717efe221 100644 --- a/apps/web/src/components/views/dialogs/UploadConfirmDialog.tsx +++ b/apps/web/src/components/views/dialogs/UploadConfirmDialog.tsx @@ -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 { @@ -35,7 +36,7 @@ export default class UploadConfirmDialog extends React.Component 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 }; 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 + {(mimeType.startsWith("image/") || mimeType.startsWith("video/")) && ( + + )} + = ({ + {vm.hasBridges && ( + + )} {!vm.isVideoRoom && ( <> ) => void; +} + +export const YapBridgesView: FC = ({ room, onClose }) => { + const events = BridgeSettingsTab.getBridgeStateEvents(room.client, room.roomId); + + return ( + +
+ {events.length === 0 ? ( +

No bridges in this room.

+ ) : ( + events.map((ev) => ) + )} +
+
+ ); +}; + +export default YapBridgesView; diff --git a/apps/web/src/components/views/rooms/EventTile.tsx b/apps/web/src/components/views/rooms/EventTile.tsx index 0be412a8da..2822f18d36 100644 --- a/apps/web/src/components/views/rooms/EventTile.tsx +++ b/apps/web/src/components/views/rooms/EventTile.tsx @@ -1263,7 +1263,14 @@ export class UnwrappedEventTile extends React.Component }, <> {ircTimestamp} - {sender} + {this.props.layout === Layout.Group && eventTileSnapshot.sender.profileMode !== "hidden" ? ( +
+ {sender} + {groupTimestamp} +
+ ) : ( + sender + )} {ircPadlock} {avatar}
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 && ( + + )} {roomName} {!isDirectMessage && joinRule === JoinRule.Public && ( @@ -533,6 +540,11 @@ export default function RoomHeader({ {isRoomEncrypted && historyVisibilityIcon(historyVisibility)} + {!isDirectMessage && topic?.text && ( + + {topic.text} + + )} {/* If the room is local-only then we don't want to show any additional buttons, as it won't work */} diff --git a/apps/web/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx b/apps/web/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx index aa7c0cf76b..7b1f75a1c4 100644 --- a/apps/web/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx +++ b/apps/web/src/components/views/rooms/RoomListPanel/RoomListPanel.tsx @@ -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 = ({ activeSpace }) => > {displayRoomSearch && } - + + ); }; diff --git a/apps/web/src/components/views/rooms/RoomListPanel/YapRoomList.tsx b/apps/web/src/components/views/rooms/RoomListPanel/YapRoomList.tsx new file mode 100644 index 0000000000..4870f400f4 --- /dev/null +++ b/apps/web/src/components/views/rooms/RoomListPanel/YapRoomList.tsx @@ -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, { 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 ( + + {meta.label} + + ); +} + +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 { + 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 ( + + {notif.count} + + ); +} + +type DragProps = React.HTMLAttributes & { draggable?: boolean }; +type RowProps = { room: Room; activeRoomId: string | null; drag: DragProps; dropPos: DropPos }; + +function ChannelRow({ room, activeRoomId, drag, dropPos }: RowProps): JSX.Element { + return ( +
viewRoom(room.roomId)} + {...drag} + > + # + {room.name} + +
+ ); +} + +/** 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 ( +
+ {members.map((m) => ( +
+ + {m.name} +
+ ))} +
+ ); +} + +function VoiceRoomRow({ room, activeRoomId, drag, dropPos }: RowProps): JSX.Element { + return ( +
+
viewRoom(room.roomId)} + {...drag} + > + + {room.name} + +
+ +
+ ); +} + +function DmRow({ room, activeRoomId, drag, dropPos }: RowProps): JSX.Element { + return ( +
viewRoom(room.roomId)} + {...drag} + > + + {room.name} + + +
+ ); +} + +function InviteRow({ room }: { room: Room }): JSX.Element { + return ( +
viewRoom(room.roomId)} + > + + {room.name} + Invite +
+ ); +} + +export function YapRoomList(): JSX.Element { + const [rooms, setRooms] = useState(currentRooms); + const [activeRoomId, setActiveRoomId] = useState(() => + SdkContextClass.instance.roomViewStore.getRoomId(), + ); + const [, setTick] = useState(0); + const dragId = useRef(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): 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(); + 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 ( +
+ {activeSpaceRoom && ( +
+ + Browse rooms + {newRoomCount > 0 && ( + + {newRoomCount} + + )} +
+ )} + + {invites.length > 0 &&
Invites
} + {invites.map((room) => ( + + ))} + + {channels.length > 0 &&
Text Channels
} + {channels.map((room) => ( + + ))} + + {voice.length > 0 &&
Voice Channels
} + {voice.map((room) => ( + + ))} + + {dms.length > 0 &&
Direct Messages
} + {dms.map((room) => ( + + ))} +
+ ); +} + +export default YapRoomList; diff --git a/apps/web/src/components/views/rooms/RoomListPanel/YapUserPanel.tsx b/apps/web/src/components/views/rooms/RoomListPanel/YapUserPanel.tsx new file mode 100644 index 0000000000..39825b5e02 --- /dev/null +++ b/apps/web/src/components/views/rooms/RoomListPanel/YapUserPanel.tsx @@ -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(() => Array.from(CallStore.instance.connectedCalls)); + + useEffect(() => { + const onProfile = (): void => setTick((t) => t + 1); + OwnProfileStore.instance.on(UPDATE_EVENT, onProfile); + const onCalls = (calls: Set): 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 ( +
+ {call && ( +
defaultDispatcher.dispatch({ action: Action.ViewRoom, room_id: call.roomId })} + > + +
+ Voice Connected + {callRoomName} +
+
+ )} +
+ +
+ {name} + {userId && {userId}} +
+
+ +
+ +
+
+ ); +} + +export default YapUserPanel; diff --git a/apps/web/src/components/views/rooms/RoomTile.tsx b/apps/web/src/components/views/rooms/RoomTile.tsx index 74bfc63221..84046f8501 100644 --- a/apps/web/src/components/views/rooms/RoomTile.tsx +++ b/apps/web/src/components/views/rooms/RoomTile.tsx @@ -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 { tooltipProps={{ tabIndex: isActive ? 0 : -1 }} /> {titleContainer} + {this.state.call && !this.props.isMinimized && ( + + )} {badge} {this.renderGeneralMenu()} {this.renderNotificationsMenu(isActive)} diff --git a/apps/web/src/components/views/rooms/YapCallFacepile.tsx b/apps/web/src/components/views/rooms/YapCallFacepile.tsx new file mode 100644 index 0000000000..965736c7cc --- /dev/null +++ b/apps/web/src/components/views/rooms/YapCallFacepile.tsx @@ -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 = ({ 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 ( +
+ + {shown.map((m) => ( + + ))} + + {extra > 0 && +{extra}} +
+ ); +}; + +export default YapCallFacepile; diff --git a/apps/web/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx b/apps/web/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx index db08c2947e..06f367e0bb 100644 --- a/apps/web/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx +++ b/apps/web/src/components/views/settings/tabs/user/VoiceUserSettingsTab.tsx @@ -208,6 +208,11 @@ export default class VoiceUserSettingsTab extends React.Component {webcamDropdown} + diff --git a/apps/web/src/components/views/spaces/SpacePanel.tsx b/apps/web/src/components/views/spaces/SpacePanel.tsx index 58ec07cd98..6a8b2174ba 100644 --- a/apps/web/src/components/views/spaces/SpacePanel.tsx +++ b/apps/web/src/components/views/spaces/SpacePanel.tsx @@ -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( 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> = () => {}; const ref = useRef(null); useLayoutEffect(() => { if (ref.current) UIStore.instance.trackElementDimensions("SpacePanel", ref.current); @@ -466,21 +467,6 @@ const SpacePanel: React.FC = () => { aria-label={_t("common|spaces")} > - setPanelCollapsed(!isPanelCollapsed)} - title={isPanelCollapsed ? _t("action|expand") : _t("action|collapse")} - caption={ - - } - > - - {(provided, snapshot) => (