Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion V2er/General/RootView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,7 +150,7 @@ struct RootHostView: View {
ZStack {
MainPage()
.buttonStyle(.plain)
.toast(isPresented: toast.isPresented) {
.toast(isPresented: toast.isPresented, version: toast.version.raw) {
DefaultToastView(title: toast.title.raw, icon: toast.icon.raw)
}
.sheet(isPresented: loginState.showLoginView) {
Expand Down
1 change: 1 addition & 0 deletions V2er/State/DataFlow/Actions/GlobalActions.swift
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ func globalStateReducer(_ state: GlobalState, _ action: Action?) -> (GlobalState
case let action as ShowToastAction:
state.toast.title = action.title
state.toast.icon = action.icon
state.toast.version += 1
state.toast.isPresented = true
break
case _ as LaunchFinishedAction:
Expand Down
130 changes: 130 additions & 0 deletions V2er/View/InAppBrowserView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -310,6 +310,136 @@ class WebViewHostController: UIViewController, WKNavigationDelegate, WKUIDelegat
DispatchQueue.main.async {
self.state.isLoading = false
}
// Inject dark mode CSS for V2EX pages if app is in dark mode
injectDarkModeIfNeeded(for: webView)
}

private func injectDarkModeIfNeeded(for webView: WKWebView) {
guard let host = webView.url?.host,
host.contains("v2ex.com") else {
return
}

// Check if app is in dark mode
let isDarkMode: Bool
if let rootStyle = V2erApp.rootViewController?.overrideUserInterfaceStyle {
switch rootStyle {
case .dark:
isDarkMode = true
case .light:
isDarkMode = false
case .unspecified:
// Follow system
isDarkMode = traitCollection.userInterfaceStyle == .dark
@unknown default:
isDarkMode = traitCollection.userInterfaceStyle == .dark
}
} else {
isDarkMode = traitCollection.userInterfaceStyle == .dark
}

guard isDarkMode else { return }

// Inject V2EX night mode CSS
let darkModeCSS = """
:root { color-scheme: dark; }
body, html {
background-color: #1a1a1a !important;
color: #e0e0e0 !important;
}
#Wrapper, #Main, #Rightbar {
background-color: #1a1a1a !important;
}
.box, .cell, .cell_ops {
background-color: #262626 !important;
border-color: #3a3a3a !important;
}
.header, .inner {
background-color: #262626 !important;
}
/* Tags */
.node, a.node, .tag, a.tag {
background-color: #3a3a3a !important;
color: #ccc !important;
}
.topic_info, .votes {
background-color: transparent !important;
}
/* Topic content */
.topic-link, .item_title a, h1 {
color: #e0e0e0 !important;
}
.topic_content, .reply_content, .markdown_body {
color: #e0e0e0 !important;
}
/* Links */
a {
color: #4a9eff !important;
}
a.node:hover, a.tag:hover {
background-color: #4a4a4a !important;
}
/* Meta text */
.gray, .fade, .small, .ago, .no {
color: #888 !important;
}
.snow {
background-color: #333 !important;
}
/* Code blocks */
pre, code {
background-color: #333 !important;
color: #e0e0e0 !important;
}
/* Forms */
input, textarea, select {
background-color: #333 !important;
color: #e0e0e0 !important;
border-color: #555 !important;
}
/* Tabs */
.tab, .tab_current {
background-color: #333 !important;
color: #ccc !important;
}
.tab:hover {
background-color: #444 !important;
}
/* Buttons */
.super.button, .normal.button, input[type=submit] {
background-color: #444 !important;
color: #e0e0e0 !important;
border-color: #555 !important;
}
/* Subtle backgrounds */
.subtle {
background-color: #2a2a2a !important;
}
/* Thank area */
.thank_area {
background-color: transparent !important;
}
/* Member info */
.member-info, .balance_area {
background-color: #262626 !important;
}
/* Separator */
hr, .sep {
border-color: #3a3a3a !important;
background-color: #3a3a3a !important;
}
/* Page title */
#page-title {
background-color: #1a1a1a !important;
}
/* Embedded */
.embedded {
background-color: #2a2a2a !important;
border-color: #3a3a3a !important;
}
"""
let js = "var style = document.createElement('style'); style.innerHTML = `\(darkModeCSS)`; document.head.appendChild(style);"
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The CSS string is embedded in a JavaScript template literal using string interpolation. While the current hardcoded CSS is safe, this pattern is fragile—if the CSS string ever contains backticks (`) or template literal expressions (${...}), it could break the JavaScript or create an injection vulnerability. Consider using proper escaping or a safer injection method, such as replacing backticks with escaped backticks in the CSS string before interpolation.

Suggested change
let js = "var style = document.createElement('style'); style.innerHTML = `\(darkModeCSS)`; document.head.appendChild(style);"
let safeDarkModeCSS = darkModeCSS
.replacingOccurrences(of: "\\", with: "\\\\")
.replacingOccurrences(of: "`", with: "\\`")
.replacingOccurrences(of: "${", with: "\\${")
let js = "var style = document.createElement('style'); style.innerHTML = `\(safeDarkModeCSS)`; document.head.appendChild(style);"

Copilot uses AI. Check for mistakes.
Copy link

Copilot AI Dec 29, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dark mode CSS is injected on every page load completion without checking if it was already injected. This could result in duplicate style tags if a page reloads or navigates within the same webView. Consider checking if the styles are already present or using a unique ID on the style tag to prevent duplicates.

Suggested change
let js = "var style = document.createElement('style'); style.innerHTML = `\(darkModeCSS)`; document.head.appendChild(style);"
let js = """
if (!document.getElementById('v2er-dark-mode-style')) {
var style = document.createElement('style');
style.id = 'v2er-dark-mode-style';
style.innerHTML = `\(darkModeCSS)`;
document.head.appendChild(style);
}
"""

Copilot uses AI. Check for mistakes.
webView.evaluateJavaScript(js, completionHandler: nil)
}

func webView(_ webView: WKWebView, didFail navigation: WKNavigation!, withError error: Error) {
Expand Down
2 changes: 1 addition & 1 deletion V2er/View/Login/LoginPage.swift
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ struct LoginPage: StateView {
dismiss()
}
}
.toast(isPresented: toast.isPresented, paddingTop: 40) {
.toast(isPresented: toast.isPresented, paddingTop: 40, version: toast.version.raw) {
DefaultToastView(title: toast.title.raw, icon: toast.icon.raw)
}
.alert(isPresented: bindingState.showAlert) {
Expand Down
11 changes: 10 additions & 1 deletion V2er/View/Widget/Toast.swift
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ final class Toast {
var isPresented: Bool = false
var title: String = ""
var icon: String = ""
var version: Int = 0 // Incremented on each new toast to trigger timer reset

static func show(_ title: String, icon: String = .empty, target: Reducer = .global) {
guard title.notEmpty() || icon.notEmpty() else { return }
Expand Down Expand Up @@ -71,6 +72,7 @@ struct DefaultToastView: View {
private struct ToastContainerView<Content: View>: View {
@Binding var isPresented: Bool
let paddingTop: CGFloat
let version: Int
let content: Content

@State private var dismissTask: Task<Void, Never>?
Expand All @@ -81,7 +83,7 @@ private struct ToastContainerView<Content: View>: View {
content
.background(Color.secondaryBackground.opacity(0.98))
.cornerRadius(99)
.shadow(color: Color.primaryText.opacity(0.3), radius: 3)
.shadow(color: Color.primaryText.opacity(0.12), radius: 4, y: 2)
.padding(.top, paddingTop)
.transition(.move(edge: .top).combined(with: .opacity))
.zIndex(1)
Expand All @@ -101,6 +103,11 @@ private struct ToastContainerView<Content: View>: View {
scheduleDismiss()
}
}
.onChange(of: version) { _ in
// New toast content: reset timer
toastId = UUID()
scheduleDismiss()
}
}

private func scheduleDismiss() {
Expand Down Expand Up @@ -133,13 +140,15 @@ private struct ToastContainerView<Content: View>: View {
extension View {
func toast<Content: View>(isPresented: Binding<Bool>,
paddingTop: CGFloat = 0,
version: Int = 0,
@ViewBuilder content: () -> Content?) -> some View {
ZStack(alignment: .top) {
self
if isPresented.wrappedValue, let toastContent = content() {
ToastContainerView(
isPresented: isPresented,
paddingTop: paddingTop,
version: version,
content: toastContent
)
}
Expand Down
Loading