Skip to content

Commit 28fd739

Browse files
cfbenderCopilot
andauthored
Apply remaining changes (#63)
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
1 parent f01c1ff commit 28fd739

9 files changed

Lines changed: 179 additions & 9 deletions

File tree

‎android/app/src/main/java/dev/cfb/manavault/MainActivity.java‎

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
package dev.cfb.manavault;
22

3+
import android.content.ActivityNotFoundException;
34
import android.content.Context;
5+
import android.content.Intent;
46
import android.graphics.Color;
57
import android.net.Uri;
68
import android.os.Bundle;
9+
import android.util.Log;
710
import android.webkit.CookieManager;
811

912
import androidx.core.view.WindowCompat;
@@ -15,6 +18,7 @@
1518
import com.getcapacitor.annotation.CapacitorPlugin;
1619

1720
public class MainActivity extends BridgeActivity {
21+
private static final String TAG = "MainActivity";
1822
private static final int APP_CHROME_COLOR = Color.rgb(24, 4, 13);
1923
private static final String PREFERENCES_NAME = "NativeShell";
2024
private static final String SERVER_URL_KEY = "serverUrl";
@@ -80,10 +84,60 @@ public Boolean shouldOverrideLoad(Uri url) {
8084
String scheme = url.getScheme();
8185

8286
if ("http".equals(scheme) || "https".equals(scheme)) {
83-
return false;
87+
if (isAppNavigation(url)) return false;
88+
89+
try {
90+
Intent intent = new Intent(Intent.ACTION_VIEW, url);
91+
intent.addCategory(Intent.CATEGORY_BROWSABLE);
92+
getActivity().startActivity(intent);
93+
} catch (ActivityNotFoundException exception) {
94+
Log.w(TAG, "No browser available to open external URL", exception);
95+
return true;
96+
}
97+
98+
return true;
8499
}
85100

86101
return null;
87102
}
103+
104+
private boolean isAppNavigation(Uri url) {
105+
String host = url.getHost();
106+
if ("manavault.cfb.dev".equalsIgnoreCase(host) || "www.manavault.cfb.dev".equalsIgnoreCase(host)) {
107+
return true;
108+
}
109+
110+
String serverUrl = getContext()
111+
.getSharedPreferences(PREFERENCES_NAME, Context.MODE_PRIVATE)
112+
.getString(SERVER_URL_KEY, "");
113+
if (serverUrl.trim().isEmpty()) return false;
114+
115+
Uri serverUri = Uri.parse(serverUrl.trim());
116+
return sameOrigin(url, serverUri);
117+
}
118+
119+
private boolean sameOrigin(Uri left, Uri right) {
120+
int leftPort = effectivePort(left);
121+
int rightPort = effectivePort(right);
122+
123+
return leftPort == rightPort
124+
&& stringEqualsIgnoreCase(left.getScheme(), right.getScheme())
125+
&& stringEqualsIgnoreCase(left.getHost(), right.getHost());
126+
}
127+
128+
private int effectivePort(Uri uri) {
129+
int port = uri.getPort();
130+
if (port >= 0) return port;
131+
132+
String scheme = uri.getScheme();
133+
if ("http".equalsIgnoreCase(scheme)) return 80;
134+
if ("https".equalsIgnoreCase(scheme)) return 443;
135+
return -1;
136+
}
137+
138+
private boolean stringEqualsIgnoreCase(String left, String right) {
139+
if (left == null || right == null) return left == null && right == null;
140+
return left.equalsIgnoreCase(right);
141+
}
88142
}
89143
}

‎assets/react/src/components/ui/dialog.tsx‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { X } from "lucide-react"
22
import { useEffect, type HTMLAttributes, type ReactNode } from "react"
33
import { createPortal } from "react-dom"
4+
import { registerNativeBackModal } from "../../lib/native-modal-stack"
45
import { cn } from "../../lib/utils"
56
import { Button } from "./button"
67

@@ -11,6 +12,12 @@ type DialogProps = {
1112
}
1213

1314
export function Dialog({ children, onOpenChange, open }: DialogProps) {
15+
useEffect(() => {
16+
if (!open) return
17+
18+
return registerNativeBackModal(() => onOpenChange(false))
19+
}, [onOpenChange, open])
20+
1421
useEffect(() => {
1522
if (!open) return
1623

‎assets/react/src/components/ui/toast.tsx‎

Lines changed: 32 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Check, X } from "lucide-react"
2-
import { createContext, useCallback, useContext, useMemo, useState, type ReactNode } from "react"
2+
import { createContext, useCallback, useContext, useEffect, useMemo, useState, type ReactNode } from "react"
33
import { createPortal } from "react-dom"
44
import { cn } from "../../lib/utils"
55
import { Button } from "./button"
@@ -18,6 +18,12 @@ type ToastContextValue = {
1818

1919
const ToastContext = createContext<ToastContextValue | null>(null)
2020
const TOAST_DISMISS_MS = 3_500
21+
const TOAST_EVENT = "manavault:toast"
22+
23+
type ToastEventDetail = {
24+
message: string
25+
tone?: ToastTone
26+
}
2127

2228
export function ToastProvider({ children }: { children: ReactNode }) {
2329
const [toasts, setToasts] = useState<ToastNotice[]>([])
@@ -37,6 +43,18 @@ export function ToastProvider({ children }: { children: ReactNode }) {
3743
[dismissToast],
3844
)
3945

46+
useEffect(() => {
47+
function handleToastEvent(event: Event) {
48+
const detail = (event as CustomEvent<ToastEventDetail>).detail
49+
if (!detail?.message) return
50+
51+
showToast(detail.message, { tone: detail.tone })
52+
}
53+
54+
window.addEventListener(TOAST_EVENT, handleToastEvent)
55+
return () => window.removeEventListener(TOAST_EVENT, handleToastEvent)
56+
}, [showToast])
57+
4058
const value = useMemo(() => ({ showToast }), [showToast])
4159

4260
return (
@@ -63,8 +81,19 @@ export function ToastProvider({ children }: { children: ReactNode }) {
6381

6482
export function useToast() {
6583
const context = useContext(ToastContext)
66-
if (!context) throw new Error("useToast must be used inside ToastProvider")
67-
return context
84+
if (context) return context
85+
86+
return {
87+
showToast(message: string, options: { tone?: ToastTone } = {}) {
88+
if (typeof window === "undefined") return
89+
90+
window.dispatchEvent(
91+
new CustomEvent<ToastEventDetail>(TOAST_EVENT, {
92+
detail: { message, tone: options.tone },
93+
}),
94+
)
95+
},
96+
} satisfies ToastContextValue
6897
}
6998

7099
export function Toast({

‎assets/react/src/lib/native-back.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
import type { BackButtonListenerEvent } from "@capacitor/app"
22
import { Capacitor, registerPlugin, type PluginListenerHandle } from "@capacitor/core"
33
import { registerCapacitorPluginOnce } from "./capacitor-native-headers.ts"
4+
import { closeTopNativeBackModal, hasNativeBackModal } from "./native-modal-stack.ts"
45

5-
export type NativeBackAction = "back" | "decks" | "minimize"
6+
export type NativeBackAction = "back" | "decks" | "modal" | "minimize"
67

78
type AppPlugin = {
89
addListener: (
@@ -23,7 +24,9 @@ export function nativeBackAction(
2324
event: BackButtonListenerEvent,
2425
pathname = window.location.pathname,
2526
browserHistoryLength = window.history.length,
27+
modalOpen = false,
2628
): NativeBackAction {
29+
if (modalOpen) return "modal"
2730
if (pathname === "/") return "minimize"
2831
if (/^\/decks\/[^/]+$/.test(pathname)) return "decks"
2932
if (event.canGoBack || browserHistoryLength > 1) return "back"
@@ -35,7 +38,12 @@ export async function initializeNativeBackButton({ pathname, navigateToDecks }:
3538

3639
try {
3740
await App.addListener("backButton", (event) => {
38-
const action = nativeBackAction(event, pathname?.())
41+
const action = nativeBackAction(event, pathname?.(), window.history.length, hasNativeBackModal())
42+
43+
if (action === "modal") {
44+
closeTopNativeBackModal()
45+
return
46+
}
3947

4048
if (action === "back") {
4149
window.history.back()
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
type NativeBackModalCloser = () => void
2+
3+
const nativeBackModalClosers: NativeBackModalCloser[] = []
4+
5+
export function registerNativeBackModal(close: NativeBackModalCloser) {
6+
nativeBackModalClosers.push(close)
7+
8+
return () => {
9+
const index = nativeBackModalClosers.lastIndexOf(close)
10+
if (index >= 0) nativeBackModalClosers.splice(index, 1)
11+
}
12+
}
13+
14+
export function hasNativeBackModal() {
15+
return nativeBackModalClosers.length > 0
16+
}
17+
18+
export function closeTopNativeBackModal() {
19+
const close = nativeBackModalClosers.at(-1)
20+
if (!close) return false
21+
22+
close()
23+
return true
24+
}

‎assets/react/src/pages/decks/deck-stack-group.tsx‎

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { isLegendaryCreature } from "./deck-card-model"
66
import { DeckStackCard } from "./deck-stack-card"
77
import {
88
isDeckStackPointerCaptured,
9+
shouldClearDeckStackTouchReveal,
910
shouldUpdateDeckStackHoverFromPointer,
1011
} from "./deck-stack-interactions"
1112
import type { DeckCardEntry, DeckCardTag } from "./deck-types"
@@ -86,6 +87,7 @@ export function DeckStackGroup({
8687
}) {
8788
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null)
8889
const [pinnedIndex, setPinnedIndex] = useState<number | null>(null)
90+
const stackRef = useRef<HTMLDivElement>(null)
8991
const hoverTimerRef = useRef<number | null>(null)
9092
const pendingHoverIndexRef = useRef<number | null>(null)
9193
const activeIndex = hoveredIndex ?? (isSelecting ? null : pinnedIndex)
@@ -98,6 +100,26 @@ export function DeckStackGroup({
98100
[],
99101
)
100102

103+
useEffect(() => {
104+
if (pinnedIndex == null) return
105+
106+
function clearPinnedCard(event: globalThis.PointerEvent) {
107+
if (
108+
!shouldClearDeckStackTouchReveal({
109+
isInsideStack: stackRef.current?.contains(event.target as Node | null) === true,
110+
isPinned: pinnedIndex != null,
111+
})
112+
) {
113+
return
114+
}
115+
116+
setPinnedIndex(null)
117+
}
118+
119+
document.addEventListener("pointerdown", clearPinnedCard, true)
120+
return () => document.removeEventListener("pointerdown", clearPinnedCard, true)
121+
}, [pinnedIndex])
122+
101123
function clearDeckCardHoverDelay() {
102124
if (hoverTimerRef.current) {
103125
clearTimeout(hoverTimerRef.current)
@@ -151,15 +173,19 @@ export function DeckStackGroup({
151173
</div>
152174

153175
<div
176+
ref={stackRef}
154177
className="relative w-56 overflow-hidden rounded-xl"
155178
style={{
156179
minHeight: `${DECK_STACK_CARD_HEIGHT + Math.max(group.cards.length - 1, 0) * DECK_STACK_OFFSET}px`,
157180
}}
158181
onPointerLeave={(event) => {
159-
if (event.pointerType !== "touch") {
160-
clearDeckCardHoverDelay()
161-
setHoveredIndex(null)
182+
clearDeckCardHoverDelay()
183+
if (event.pointerType === "touch") {
184+
setPinnedIndex(null)
185+
return
162186
}
187+
188+
setHoveredIndex(null)
163189
}}
164190
onPointerMove={handlePointerMove}
165191
>

‎assets/react/src/pages/decks/deck-stack-interactions.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,3 +82,13 @@ export function shouldRevealDeckStackCardOnPointerDown({
8282
}) {
8383
return pointerType !== "mouse" && !isActive
8484
}
85+
86+
export function shouldClearDeckStackTouchReveal({
87+
isInsideStack,
88+
isPinned,
89+
}: {
90+
isInsideStack: boolean
91+
isPinned: boolean
92+
}) {
93+
return isPinned && !isInsideStack
94+
}

‎assets/react/test/deck-stack-interactions.test.mjs‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
shouldCloseDeckStackActionMenu,
1111
shouldRaiseDeckStackCardForActionMenu,
1212
shouldRevealDeckStackCardOnPointerDown,
13+
shouldClearDeckStackTouchReveal,
1314
shouldUpdateDeckStackHoverFromPointer,
1415
} from "../src/pages/decks/deck-stack-interactions.ts"
1516

@@ -107,3 +108,9 @@ test("deck stack action menu raises its card before opening", () => {
107108
assert.equal(shouldRaiseDeckStackCardForActionMenu({ isActive: false }), true)
108109
assert.equal(shouldRaiseDeckStackCardForActionMenu({ isActive: true }), false)
109110
})
111+
112+
test("deck stack touch reveal clears only for outside touches", () => {
113+
assert.equal(shouldClearDeckStackTouchReveal({ isInsideStack: false, isPinned: true }), true)
114+
assert.equal(shouldClearDeckStackTouchReveal({ isInsideStack: true, isPinned: true }), false)
115+
assert.equal(shouldClearDeckStackTouchReveal({ isInsideStack: false, isPinned: false }), false)
116+
})

‎assets/react/test/native-back.test.mjs‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,11 @@ import assert from "node:assert/strict"
33

44
import { nativeBackAction } from "../src/lib/native-back.ts"
55

6+
test("nativeBackAction closes open modals before navigation", () => {
7+
assert.equal(nativeBackAction({ canGoBack: true }, "/collection", 2, true), "modal")
8+
assert.equal(nativeBackAction({ canGoBack: true }, "/", 2, true), "modal")
9+
})
10+
611
test("nativeBackAction minimizes on home even with a back stack", () => {
712
assert.equal(nativeBackAction({ canGoBack: true }, "/", 2), "minimize")
813
})

0 commit comments

Comments
 (0)