337 lines
15 KiB
Swift
337 lines
15 KiB
Swift
@preconcurrency import SwiftUI
|
|
@preconcurrency import WebKit
|
|
|
|
struct BulletinView: View {
|
|
@State private var isLoading = true
|
|
@Environment(\.dismiss) private var dismiss
|
|
|
|
var body: some View {
|
|
WebViewWithRefresh(url: URL(string: "https://rtsda.updates.church")!, isLoading: $isLoading)
|
|
.navigationTitle("Bulletin")
|
|
.navigationBarTitleDisplayMode(.inline)
|
|
.background(Color(uiColor: .systemBackground))
|
|
.toolbar {
|
|
ToolbarItem(placement: .navigationBarLeading) {
|
|
if isLoading {
|
|
ProgressView()
|
|
.progressViewStyle(.circular)
|
|
}
|
|
}
|
|
ToolbarItem(placement: .navigationBarTrailing) {
|
|
Button(action: {
|
|
dismiss()
|
|
}) {
|
|
Image(systemName: "xmark")
|
|
.foregroundColor(.primary)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct WebViewWithRefresh: UIViewRepresentable {
|
|
let url: URL
|
|
@Binding var isLoading: Bool
|
|
|
|
func makeCoordinator() -> Coordinator {
|
|
Coordinator(self)
|
|
}
|
|
|
|
func makeUIView(context: Context) -> WKWebView {
|
|
// Create configuration with script message handler
|
|
let configuration = WKWebViewConfiguration()
|
|
let contentController = WKUserContentController()
|
|
|
|
// Add hymn detection script
|
|
let hymnDetectionScript = """
|
|
function detectAndModifyHymns() {
|
|
// Regular expression to match patterns like:
|
|
// - "Hymn XXX" or "Hymnal XXX" with optional quotes and title
|
|
// - "#XXX" with optional quotes and title
|
|
// - But NOT match when the number is followed by a colon (e.g., "10:45")
|
|
// - And NOT match when the number is actually part of a larger number
|
|
const hymnRegex = /(?:(hymn(?:al)?\\s+#?)|#)(\\d+)(?![\\d:\\.]|\\d*[apm])(?:\\s+["']([^"']+)["'])?/gi;
|
|
|
|
// Extra check before creating links
|
|
function isValidHymnNumber(text, matchIndex, number) {
|
|
// Make sure this is not part of a time (e.g., "Hymn 10:45am")
|
|
const afterMatch = text.substring(matchIndex + number.length);
|
|
if (afterMatch.match(/^\\s*[:.]\\d|\\d*[apm]/)) {
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
// Function to replace text with a styled link
|
|
function replaceWithLink(node) {
|
|
if (node.nodeType === 3) {
|
|
// Text node
|
|
const content = node.textContent;
|
|
if (hymnRegex.test(content)) {
|
|
// Reset regex lastIndex
|
|
hymnRegex.lastIndex = 0;
|
|
|
|
// Create a temporary element
|
|
const span = document.createElement('span');
|
|
let lastIndex = 0;
|
|
let match;
|
|
|
|
// Find all matches and replace them with links
|
|
while ((match = hymnRegex.exec(content)) !== null) {
|
|
// Add text before the match
|
|
if (match.index > lastIndex) {
|
|
span.appendChild(document.createTextNode(content.substring(lastIndex, match.index)));
|
|
}
|
|
|
|
// Get the hymn number
|
|
const hymnNumber = match[2];
|
|
|
|
// Extra validation to ensure this isn't part of a time
|
|
const prefixLength = match[0].length - hymnNumber.length;
|
|
const numberStartIndex = match.index + prefixLength;
|
|
|
|
if (!isValidHymnNumber(content, numberStartIndex, hymnNumber)) {
|
|
// Just add the original text if it's not a valid hymn reference
|
|
span.appendChild(document.createTextNode(match[0]));
|
|
} else {
|
|
// Create link element for valid hymn numbers
|
|
const hymnTitle = match[3] ? ': ' + match[3] : '';
|
|
const link = document.createElement('a');
|
|
link.textContent = match[0];
|
|
link.href = 'javascript:void(0)';
|
|
link.className = 'hymn-link';
|
|
link.setAttribute('data-hymn-number', hymnNumber);
|
|
link.style.color = '#0070c9';
|
|
link.style.textDecoration = 'underline';
|
|
link.style.fontWeight = 'bold';
|
|
link.onclick = function(e) {
|
|
e.preventDefault();
|
|
window.webkit.messageHandlers.hymnHandler.postMessage({ number: hymnNumber });
|
|
};
|
|
|
|
span.appendChild(link);
|
|
}
|
|
|
|
lastIndex = match.index + match[0].length;
|
|
}
|
|
|
|
// Add any remaining text
|
|
if (lastIndex < content.length) {
|
|
span.appendChild(document.createTextNode(content.substring(lastIndex)));
|
|
}
|
|
|
|
// Replace the original node with our span containing links
|
|
if (span.childNodes.length > 0) {
|
|
node.parentNode.replaceChild(span, node);
|
|
}
|
|
}
|
|
} else if (node.nodeType === 1 && node.nodeName !== 'SCRIPT' && node.nodeName !== 'STYLE' && node.nodeName !== 'A') {
|
|
// Element node, not a script or style tag or already a link
|
|
Array.from(node.childNodes).forEach(child => replaceWithLink(child));
|
|
}
|
|
}
|
|
|
|
// Process the document body
|
|
replaceWithLink(document.body);
|
|
|
|
console.log('Hymn detection script executed');
|
|
}
|
|
|
|
// Call the function after page has loaded and whenever content changes
|
|
detectAndModifyHymns();
|
|
|
|
// Use a MutationObserver to detect DOM changes and reapply the links
|
|
const observer = new MutationObserver(mutations => {
|
|
detectAndModifyHymns();
|
|
});
|
|
observer.observe(document.body, { childList: true, subtree: true });
|
|
"""
|
|
|
|
let userScript = WKUserScript(
|
|
source: hymnDetectionScript,
|
|
injectionTime: .atDocumentEnd,
|
|
forMainFrameOnly: false
|
|
)
|
|
|
|
contentController.addUserScript(userScript)
|
|
contentController.add(context.coordinator, name: "hymnHandler")
|
|
configuration.userContentController = contentController
|
|
|
|
// Create the web view with our configuration
|
|
let webView = WKWebView(frame: .zero, configuration: configuration)
|
|
webView.navigationDelegate = context.coordinator
|
|
|
|
// Add swipe gesture recognizer
|
|
let swipeGesture = UISwipeGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleSwipe(_:)))
|
|
swipeGesture.direction = .right
|
|
webView.addGestureRecognizer(swipeGesture)
|
|
|
|
// Configure refresh control with custom appearance
|
|
let refreshControl = UIRefreshControl()
|
|
refreshControl.addTarget(context.coordinator,
|
|
action: #selector(Coordinator.handleRefresh),
|
|
for: .valueChanged)
|
|
// Set the refresh control's background color
|
|
refreshControl.backgroundColor = .clear
|
|
webView.scrollView.refreshControl = refreshControl
|
|
|
|
// Set background colors
|
|
webView.isOpaque = false
|
|
webView.backgroundColor = .clear
|
|
webView.scrollView.backgroundColor = .clear
|
|
|
|
let request = URLRequest(url: url)
|
|
webView.load(request)
|
|
return webView
|
|
}
|
|
|
|
func updateUIView(_ webView: WKWebView, context: Context) {
|
|
// No updates needed
|
|
}
|
|
|
|
class Coordinator: NSObject, WKNavigationDelegate, WKScriptMessageHandler {
|
|
var parent: WebViewWithRefresh
|
|
|
|
init(_ parent: WebViewWithRefresh) {
|
|
self.parent = parent
|
|
}
|
|
|
|
// Handle messages from JavaScript
|
|
func userContentController(_ userContentController: WKUserContentController, didReceive message: WKScriptMessage) {
|
|
if message.name == "hymnHandler" {
|
|
guard let body = message.body as? [String: Any],
|
|
let hymnNumberString = body["number"] as? String,
|
|
let hymnNumber = Int(hymnNumberString) else {
|
|
print("❌ Invalid hymn number received")
|
|
return
|
|
}
|
|
|
|
print("🎵 Opening hymn #\(hymnNumber)")
|
|
AppAvailabilityService.shared.openHymnByNumber(hymnNumber)
|
|
}
|
|
}
|
|
|
|
@objc func handleSwipe(_ gesture: UISwipeGestureRecognizer) {
|
|
print("🔄 Swipe detected")
|
|
if gesture.state == .ended {
|
|
if let webView = gesture.view as? WKWebView {
|
|
print("📱 Attempting to trigger back action")
|
|
|
|
// JavaScript to click the Chakra UI back button
|
|
let script = """
|
|
function findBackButton() {
|
|
// Common back button selectors
|
|
var selectors = [
|
|
'button[aria-label="Go back"]',
|
|
'button.chakra-button[aria-label*="back"]',
|
|
'button.chakra-button svg[aria-label*="back"]',
|
|
'button.chakra-button span svg[aria-hidden="true"]',
|
|
'button svg[data-icon="arrow-left"]',
|
|
'button.chakra-button svg',
|
|
'button.chakra-button'
|
|
];
|
|
|
|
for (var i = 0; i < selectors.length; i++) {
|
|
var buttons = document.querySelectorAll(selectors[i]);
|
|
for (var j = 0; j < buttons.length; j++) {
|
|
var button = buttons[j];
|
|
// Check if it looks like a back button
|
|
if (button.textContent.toLowerCase().includes('back') ||
|
|
button.getAttribute('aria-label')?.toLowerCase().includes('back') ||
|
|
button.innerHTML.toLowerCase().includes('back')) {
|
|
console.log('Found back button:', button.outerHTML);
|
|
return button;
|
|
}
|
|
}
|
|
}
|
|
console.log('No back button found');
|
|
return null;
|
|
}
|
|
|
|
var backButton = findBackButton();
|
|
if (backButton) {
|
|
backButton.click();
|
|
true;
|
|
} else {
|
|
false;
|
|
}
|
|
"""
|
|
|
|
webView.evaluateJavaScript(script) { result, error in
|
|
if let error = error {
|
|
print("❌ JavaScript error: \(error.localizedDescription)")
|
|
} else if let success = result as? Bool {
|
|
print(success ? "✅ Back button clicked" : "❌ No back button found")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@objc func handleRefresh(sender: UIRefreshControl) {
|
|
parent.isLoading = true
|
|
if let webView = sender.superview?.superview as? WKWebView {
|
|
// Clear all website data
|
|
WKWebsiteDataStore.default().removeData(
|
|
ofTypes: [WKWebsiteDataTypeDiskCache, WKWebsiteDataTypeMemoryCache],
|
|
modifiedSince: Date(timeIntervalSince1970: 0)
|
|
) {
|
|
DispatchQueue.main.async {
|
|
// Create a fresh request
|
|
if let url = webView.url {
|
|
let request = URLRequest(url: url, cachePolicy: .reloadIgnoringLocalAndRemoteCacheData)
|
|
webView.load(request)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Navigation delegate methods
|
|
func webView(_ webView: WKWebView, didStartProvisionalNavigation navigation: WKNavigation!) {
|
|
parent.isLoading = true
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFinish navigation: WKNavigation!) {
|
|
parent.isLoading = false
|
|
webView.scrollView.refreshControl?.endRefreshing()
|
|
|
|
// Execute the hymn detection script again after the page loads
|
|
let rerunScript = "detectAndModifyHymns();"
|
|
webView.evaluateJavaScript(rerunScript) { _, error in
|
|
if let error = error {
|
|
print("❌ Error running hymn detection script: \(error.localizedDescription)")
|
|
} else {
|
|
print("✅ Hymn detection script executed after page load")
|
|
}
|
|
}
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
|
|
parent.isLoading = false
|
|
webView.scrollView.refreshControl?.endRefreshing()
|
|
}
|
|
|
|
func webView(_ webView: WKWebView, didFailProvisionalNavigation navigation: WKNavigation!, withError error: Error) {
|
|
parent.isLoading = false
|
|
webView.scrollView.refreshControl?.endRefreshing()
|
|
}
|
|
|
|
// Handle back navigation
|
|
func webView(_ webView: WKWebView, decidePolicyFor navigationAction: WKNavigationAction, decisionHandler: @escaping (WKNavigationActionPolicy) -> Void) {
|
|
if navigationAction.navigationType == .backForward {
|
|
decisionHandler(.cancel)
|
|
parent.isLoading = false
|
|
return
|
|
}
|
|
decisionHandler(.allow)
|
|
}
|
|
}
|
|
}
|
|
|
|
#Preview {
|
|
NavigationStack {
|
|
BulletinView()
|
|
}
|
|
}
|