From 7b1bfa71c0ebc021d4cd1acd1a7bf3e88a3ab274 Mon Sep 17 00:00:00 2001 From: thepra Date: Tue, 6 Oct 2026 23:39:10 +0200 Subject: [PATCH] On touch, a tap on a post opens its thread On a phone the only way into a post's thread was a small Expand button (and now an entry in More), while a tap on the post pinned it to a globe that is usually hidden there, outlining the card. Where the pointer can't hover, a tap on a post that isn't on its links, controls or menus and doesn't end a text selection opens its thread through the card's own thread link (main.js, data-thread on the card); cards are pinned to the globe with a mouse only. The globe's corner stats have 12px text on touch. Phone flow: a tap in a post's margin opens its thread and pins nothing. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LsXgEaXee4GCU1hwYgPJXw --- Components/Content.razor | 2 +- Components/Feed/GlobePanel.razor | 4 ++-- tests/e2e/Mobile/FlowTests.cs | 15 +++++++++++++++ wwwroot/js/globe.js | 6 ++++-- wwwroot/main.js | 17 +++++++++++++++++ 5 files changed, 39 insertions(+), 5 deletions(-) diff --git a/Components/Content.razor b/Components/Content.razor index 7828c01..7e93bd4 100644 --- a/Components/Content.razor +++ b/Components/Content.razor @@ -17,7 +17,7 @@ data-src="@(Compact ? null : Post.Location?.Source.ToString().ToLowerInvariant())" data-cdn="@(Compact ? null : Post.Location?.CdnKey)" data-label="@(Compact ? null : Post.Location?.Label)" data-host="@(Compact ? null : Post.Host)" data-range="@(Post.Located && !Compact ? Coordinate(Post.RangeKm) : null)" data-post-at="@(Compact ? null : Post.WrittenAt?.ToString("O"))" - data-uri="@(Compact ? null : Post.Status?.Uri)"> + data-uri="@(Compact ? null : Post.Status?.Uri)" data-thread="@(IncludeExpand && !Compact && Status is not null && !Post.Located ? "" : null)"> @if (Author is { } author) { diff --git a/Components/Feed/GlobePanel.razor b/Components/Feed/GlobePanel.razor index 40358b7..01b085d 100644 --- a/Components/Feed/GlobePanel.razor +++ b/Components/Feed/GlobePanel.razor @@ -14,7 +14,7 @@ @if (stats?.Sun is { } sun) { -
+
@@ -50,7 +50,7 @@ } -
+
diff --git a/tests/e2e/Mobile/FlowTests.cs b/tests/e2e/Mobile/FlowTests.cs index f452a9b..845b524 100644 --- a/tests/e2e/Mobile/FlowTests.cs +++ b/tests/e2e/Mobile/FlowTests.cs @@ -262,6 +262,21 @@ namespace decePubClient.E2E.Mobile } } + // a tap on a post (in its margin, not on a link or a control) opens its thread + [Fact] + public async Task A_tap_on_a_post_opens_its_thread() + { + await using var session = await SignIn.As(browser, Persona(), Phone); + await Flow(session, "thread", async page => + { + var card = Card(page, "a[data-thread-link]"); + var box = (await card.BoundingBoxAsync())!; + await card.TapAsync(new() { Position = new() { X = box.Width - 5, Y = box.Height * 0.35f } }); + await page.WaitForURLAsync(url => new Uri(url).AbsolutePath.StartsWith("/expand/"), new() { Timeout = 15000 }); + Assert.Empty(await page.Locator("[data-globe-pinned]").AllAsync()); + }); + } + static async Task Latest(Pasture.Persona persona) => (await PersonaApi.Call(persona, $"/api/v1/accounts/{persona.LocalId}/statuses?limit=1"))!.AsArray().FirstOrDefault(); } diff --git a/wwwroot/js/globe.js b/wwwroot/js/globe.js index 16a1f00..534d59d 100644 --- a/wwwroot/js/globe.js +++ b/wwwroot/js/globe.js @@ -21,6 +21,8 @@ const vendor = new URL("../vendor/globe/", import.meta.url).href const textures = { day: vendor + "earth-blue-marble.jpg", night: vendor + "earth-night.jpg" } const countriesUrl = vendor + "countries.json" const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)") +// a touch screen: a tap on a card opens its thread there (main.js), so cards are pinned with a mouse only +const canHover = matchMedia("(hover: hover)") let loading = null let state = null @@ -597,10 +599,10 @@ function watchCards() { hover(null) }) // a click on a card (not on its links or controls) pins it: the globe keeps its moment and place until it is - // clicked again or another card is pinned + // clicked again or another card is pinned. With a mouse only: on a touch screen the tap opens the post's thread listen(scroller, "click", event => { const card = event.target.closest?.("[data-post-id]") - if (!card || event.target.closest("a, button, input, textarea, select, label, summary, video, audio, [role=button]")) + if (!canHover.matches || !card || event.target.closest("a, button, input, textarea, select, label, summary, video, audio, [role=button]")) return const key = card.dataset.postId scroller.querySelectorAll("[data-globe-pinned]").forEach(c => c.removeAttribute("data-globe-pinned")) diff --git a/wwwroot/main.js b/wwwroot/main.js index 7db0b90..b09025d 100644 --- a/wwwroot/main.js +++ b/wwwroot/main.js @@ -123,6 +123,23 @@ window.clearAppBadge = () => { window.logFromJs(e.message, "clearAppBadge") } } +// On a touch screen a tap on a post (not on its links, controls or menus, and not ending a text selection) opens its +// thread, through the card's own thread link (Content.razor, data-thread-link), which Blazor's router takes. +document.addEventListener("click", event => { + try { + if (window.matchMedia("(hover: hover)").matches || !(event.target instanceof Element)) + return + const card = event.target.closest("[data-thread]") + if (!card || event.target.closest("a, button, input, textarea, select, label, summary, video, audio, [role=button], .dropdown, .modal, [data-sheet]")) + return + if (!window.getSelection()?.isCollapsed) + return + card.querySelector("a[data-thread-link]")?.click() + } catch (e) { + console.error(e) + window.logFromJs(e.message, "openThread") + } +}) window.canHover = () => { try { return window.matchMedia("(hover: hover)").matches