From a6bc9da00964d1879990aa070f98e12f6ae09ed2 Mon Sep 17 00:00:00 2001 From: Evgeny Date: Thu, 18 Jun 2026 08:41:05 +0100 Subject: [PATCH] web: improve channel layout, fix subscriber count (#7092) * web: improve channel layout * limit link preview width * fix * update subscriber count in relays * catch worker errors --------- Co-authored-by: Evgeny @ SimpleX Chat <259188159+evgeny-simplex@users.noreply.github.com> --- src/Simplex/Chat/Library/Internal.hs | 3 ++ src/Simplex/Chat/Web.hs | 21 ++++++----- website/src/js/channel-preview.jsc | 53 +++++++++++++++++++++++----- 3 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/Simplex/Chat/Library/Internal.hs b/src/Simplex/Chat/Library/Internal.hs index 086c7e69d5..5439385437 100644 --- a/src/Simplex/Chat/Library/Internal.hs +++ b/src/Simplex/Chat/Library/Internal.hs @@ -1376,6 +1376,9 @@ updatePublicGroupData user gInfo pure (gInfo', gLink) setGroupLinkDataAsync user gInfo' gLink pure gInfo' + | useRelays' gInfo && isRelay (membership gInfo) = do + cxt <- chatStoreCxt + withStore $ \db -> updatePublicMemberCount db cxt user gInfo | otherwise = pure gInfo updateGroupFromLinkData :: User -> GroupInfo -> GroupShortLinkData -> CM (GroupInfo, Bool) diff --git a/src/Simplex/Chat/Web.hs b/src/Simplex/Chat/Web.hs index 2b4fb89137..fc3e4b2a26 100644 --- a/src/Simplex/Chat/Web.hs +++ b/src/Simplex/Chat/Web.hs @@ -26,7 +26,7 @@ where import Control.Concurrent.STM (check, flushTQueue) import Control.Exception (SomeException, catch) import Control.Logger.Simple -import Control.Monad (forM_, void, when) +import Control.Monad import Control.Monad.Except (runExceptT) import Data.Either (rights) import Data.Int (Int64) @@ -42,7 +42,7 @@ import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as TIO import Data.Time.Clock (UTCTime, getCurrentTime) -import Simplex.Chat.Controller (ChatConfig (..), ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt) +import Simplex.Chat.Controller (ChatController (..), CorsOrigin (..), PublishableGroup (..), WebPreviewConfig (..), WebPreviewState (..), mkStoreCxt) import Simplex.Chat.Markdown (FormattedText (..), MarkdownList, parseMaybeMarkdownList) import Simplex.Chat.Messages ( CChatItem (..), @@ -57,7 +57,7 @@ import Simplex.Chat.Messages ) import Simplex.Chat.Messages.CIContent (ciMsgContent) import Simplex.Chat.Protocol (MsgContent, MsgRef (..), QuotedMsg (..), isReport) -import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups) +import Simplex.Chat.Store.Groups (getGroupOwners, getRelayPublishableGroups, updatePublicMemberCount) import Simplex.Chat.Store.Messages (getGroupWebPreviewItems) import Simplex.Chat.Store.Shared (getGroupInfo) import Simplex.Chat.Types @@ -75,11 +75,11 @@ import Simplex.Chat.Types ) import Simplex.Messaging.Agent.Store.Common (withTransaction) import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Util (safeDecodeUtf8) -import qualified URI.ByteString as U +import Simplex.Messaging.Util (catchOwn, eitherToMaybe, safeDecodeUtf8, tshow) import Simplex.Messaging.Parsers (defaultJSON) import System.Directory (createDirectoryIfMissing, listDirectory, removeFile, renameFile) import System.FilePath (dropExtension, takeExtension, ()) +import qualified URI.ByteString as U import UnliftIO.STM data WebFileInfo = WebFileInfo @@ -135,7 +135,7 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva cleanStaleFiles wps regenerateCors wps seedRoutinePending wps - workerLoop wps + forever $ workerLoop wps `catchOwn` \e -> logError ("web preview worker error: " <> tshow e) where cxt = mkStoreCxt (config cc) @@ -146,7 +146,6 @@ webPreviewWorker cfg@WebPreviewConfig {webJsonDir, webCorsFile, webUpdateInterva renderRoutine noRoutine <- atomically $ S.null <$> readTVar routinePending when noRoutine waitRefresh - workerLoop wps where drainRemovals = atomically (tryReadTQueue filesToRemove) >>= \case Nothing -> pure () @@ -233,6 +232,12 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI case publicGroup of Just PublicGroupProfile {publicGroupId, publicGroupAccess} -> do let fName = publicGroupIdFileName publicGroupId <> ".json" + -- backfill the subscriber count for channels created before it was tracked + subscribers <- case publicMemberCount of + Just _ -> pure publicMemberCount + Nothing -> do + g_ <- withTransaction (chatStore cc) (\db -> runExceptT $ updatePublicMemberCount db cxt user gInfo) + pure $ eitherToMaybe g_ >>= \GroupInfo {groupSummary = GroupSummary {publicMemberCount = pmc}} -> pmc (items, owners) <- withTransaction (chatStore cc) $ \db -> do is <- getGroupWebPreviewItems db user gInfo webPreviewItemCount os <- getGroupOwners db cxt user gInfo @@ -246,7 +251,7 @@ renderGroupPreview WebPreviewConfig {webJsonDir, webPreviewItemCount} cc user gI shortDescription = toFormattedText =<< sd, welcomeMessage = toFormattedText =<< wd, members = senders, - subscribers = publicMemberCount, + subscribers, messages = msgs, updatedAt = ts } diff --git a/website/src/js/channel-preview.jsc b/website/src/js/channel-preview.jsc index 5cd4a390cb..b65c781a85 100644 --- a/website/src/js/channel-preview.jsc +++ b/website/src/js/channel-preview.jsc @@ -1,7 +1,7 @@ -#include "simplex-lib.jsc" - (function() { +#include "simplex-lib.jsc" + #include "qrcode.js" const STYLE = ` @@ -290,6 +290,7 @@ const STYLE = ` .simplex-preview-quote-text { font-size: 15px; overflow: hidden; + overflow-wrap: anywhere; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; @@ -1051,6 +1052,32 @@ function renderAppBadges(container) { container.appendChild(badges); } +// the QR library only parses hex colors; map 'transparent' (used in dark mode) to a transparent hex +function qrColor(value, fallback) { + value = (value || '').trim(); + if (!value) return fallback; + return value === 'transparent' ? '#0000' : value; +} + +// navigator.clipboard is undefined outside secure contexts; fall back to execCommand +function copyToClipboard(text) { + if (navigator.clipboard && navigator.clipboard.writeText) { + return navigator.clipboard.writeText(text); + } + return new Promise(function(resolve, reject) { + const ta = document.createElement('textarea'); + ta.value = text; + ta.style.position = 'fixed'; + ta.style.top = '-9999px'; + document.body.appendChild(ta); + ta.select(); + let ok = false; + try { ok = document.execCommand('copy'); } catch (e) {} + document.body.removeChild(ta); + ok ? resolve() : reject(new Error('copy failed')); + }); +} + function renderDesktopConversion(container, channelLink, showAppBadges) { if (showAppBadges) { renderAppBadges(container); @@ -1093,8 +1120,8 @@ function renderDesktopConversion(container, channelLink, showAppBadges) { QRCode.toCanvas(canvas, channelLink, { errorCorrectionLevel: 'M', color: { - dark: cs.getPropertyValue('--sp-qr-fg').trim() || '#062D56', - light: cs.getPropertyValue('--sp-qr-bg').trim() || '#ffffff' + dark: qrColor(cs.getPropertyValue('--sp-qr-fg'), '#062D56'), + light: qrColor(cs.getPropertyValue('--sp-qr-bg'), '#ffffff') }, width: 400, margin: 1 @@ -1102,12 +1129,14 @@ function renderDesktopConversion(container, channelLink, showAppBadges) { canvas.style.width = '200px'; canvas.style.height = '200px'; }).catch(function() { + canvas._rendered = false; qrPopup.style.display = 'none'; - qrToggle.style.display = 'none'; + qrToggle.style.display = ''; }); } catch(err) { + canvas._rendered = false; qrPopup.style.display = 'none'; - qrToggle.style.display = 'none'; + qrToggle.style.display = ''; } } } else { @@ -1123,10 +1152,10 @@ function renderDesktopConversion(container, channelLink, showAppBadges) { const copyLink = document.createElement('a'); copyLink.textContent = 'copy link'; copyLink.addEventListener('click', function() { - navigator.clipboard.writeText(channelLink).then(function() { + copyToClipboard(channelLink).then(function() { copyLink.textContent = 'Copied!'; setTimeout(function() { copyLink.textContent = 'copy link'; }, 2000); - }); + }).catch(function() {}); }); copyAction.appendChild(document.createTextNode('Or ')); copyAction.appendChild(copyLink); @@ -1341,7 +1370,11 @@ function renderQuote(quote, membersMap, channel) { function classifyImage(img) { const w = img.naturalWidth; const h = img.naturalHeight; - img.classList.add(w * 0.97 <= h ? 'portrait' : 'landscape'); + const portrait = w * 0.97 <= h; + img.classList.add(portrait ? 'portrait' : 'landscape'); + // constrain the bubble to the image width so long text wraps instead of widening the bubble + const inner = img.closest('.simplex-preview-bubble-inner'); + if (inner) inner.style.maxWidth = portrait ? '300px' : '400px'; } function renderImageContent(inner, mc, msg, mediaOnly) { @@ -1395,6 +1428,8 @@ function renderVideoContent(inner, mc, msg, mediaOnly) { function renderLinkContent(bubble, mc, msg) { if (mc.preview) { + // clamp the bubble to the link card width so long text wraps instead of widening the bubble + bubble.style.maxWidth = '400px'; const card = document.createElement('div'); card.className = 'simplex-preview-link-card'; if (isDataImage(mc.preview.image)) {