feat: implement first-launch language picker and remove deprecated language selection view

This commit is contained in:
Sarto
2026-05-07 20:27:34 +03:30
parent d1f6ca532e
commit 347ad5cbd1
6 changed files with 71 additions and 180 deletions
-93
View File
@@ -1,93 +0,0 @@
name: iOS Release (preview)
on:
workflow_dispatch:
inputs:
tag:
description: 'Tag for the pre-release (e.g. v0.15.0-ios.1)'
required: true
default: 'v0.0.0-ios.0'
push:
tags:
- 'v*-ios.*'
- 'v*-ios-*'
permissions:
contents: write
jobs:
build:
runs-on: macos-14
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.26'
cache: true
- name: Select Xcode
run: sudo xcode-select -s /Applications/Xcode.app
- name: Install gomobile + gobind
run: |
go install golang.org/x/mobile/cmd/gomobile@latest
go install golang.org/x/mobile/cmd/gobind@latest
gomobile init
go get golang.org/x/mobile/bind golang.org/x/mobile/bind/objc
go mod tidy
- name: Build Mobile.xcframework
run: gomobile bind -iosversion=14.0 -target=ios,iossimulator -o ios/Mobile.xcframework ./mobile
- name: Archive (unsigned)
run: |
xcodebuild \
-project ios/Thefeed.xcodeproj \
-scheme Thefeed \
-configuration Release \
-destination 'generic/platform=iOS' \
-archivePath build/Thefeed.xcarchive \
archive \
CODE_SIGN_IDENTITY="" \
CODE_SIGNING_REQUIRED=NO \
CODE_SIGNING_ALLOWED=NO \
DEVELOPMENT_TEAM=""
- name: Pack unsigned IPA
run: |
mkdir -p build/Payload
cp -r build/Thefeed.xcarchive/Products/Applications/Thefeed.app build/Payload/
(cd build && zip -qry "Thefeed-${GITHUB_REF_NAME:-${{ github.event.inputs.tag }}}-unsigned.ipa" Payload)
ls -lh build/*.ipa
- name: Resolve tag
id: tag
run: |
TAG="${GITHUB_REF_NAME}"
if [ -z "$TAG" ] || [ "$TAG" = "main" ]; then TAG="${{ github.event.inputs.tag }}"; fi
echo "tag=$TAG" >> "$GITHUB_OUTPUT"
- name: Create pre-release
uses: softprops/action-gh-release@v2
with:
tag_name: ${{ steps.tag.outputs.tag }}
name: ${{ steps.tag.outputs.tag }} (iOS preview)
prerelease: true
generate_release_notes: false
body: |
⚠️ **iOS-only preview release.**
This release ships only the iOS build. Other platforms
(server, Linux/macOS/Windows clients, Android APKs) are not
included — pull the latest stable tag for those.
The IPA is **unsigned**. To install:
- Re-sign with your own provisioning profile and distribute via TestFlight, or
- Use a sideloading tool such as AltStore / Sideloadly with your own Apple ID.
iOS 14.0 or newer required. Universal binary (iPhone + iPad).
files: |
build/Thefeed-*.ipa
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+67 -4
View File
@@ -2940,6 +2940,51 @@
</head>
<body>
<!-- First-launch language picker. Visible until lang is set; pure HTML/JS
so it works on iOS, Android, and the browser without native code. -->
<div id="firstRunLangModal" style="display:none;position:fixed;inset:0;z-index:99999;background:#0f1722;color:#fff;align-items:center;justify-content:center;flex-direction:column;padding:24px;font-family:-apple-system,BlinkMacSystemFont,system-ui,sans-serif">
<div style="font-size:38px;font-weight:700;margin-bottom:6px;letter-spacing:-0.5px">TheFeed</div>
<div style="opacity:.6;font-size:14px;margin-bottom:36px;text-align:center">Choose your language &nbsp;·&nbsp; زبان خود را انتخاب کنید</div>
<div style="display:flex;flex-direction:column;gap:14px;width:100%;max-width:320px">
<button onclick="firstRunPickLang('en')" style="background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:#fff;border-radius:14px;padding:18px;font-size:18px;font-weight:600;cursor:pointer">English<div style="font-size:12px;font-weight:400;opacity:.7;margin-top:2px">Continue in English</div></button>
<button onclick="firstRunPickLang('fa')" style="background:rgba(255,255,255,.08);border:1px solid rgba(255,255,255,.15);color:#fff;border-radius:14px;padding:18px;font-size:18px;font-weight:600;cursor:pointer">فارسی<div style="font-size:12px;font-weight:400;opacity:.7;margin-top:2px">ادامه به زبان فارسی</div></button>
</div>
</div>
<script>
(function () {
// Server is the source of truth: wiping thefeeddata/ resets lang
// along with everything else. Sync XHR keeps this decision before
// first paint so the modal never flashes for returning users.
var lang = '';
try {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/settings', false);
xhr.send();
if (xhr.status === 200) lang = (JSON.parse(xhr.responseText).lang || '');
} catch (e) {
// Server unreachable — fall back to client-side caches.
try { if (typeof IOS !== 'undefined' && IOS.getLang) lang = IOS.getLang() || ''; } catch (e2) { }
try { if (!lang && typeof Android !== 'undefined' && Android.getLang) lang = Android.getLang() || ''; } catch (e2) { }
if (!lang) lang = localStorage.getItem('thefeed_lang') || '';
}
if (!lang) document.getElementById('firstRunLangModal').style.display = 'flex';
})();
function firstRunPickLang(l) {
// Persist server-side synchronously so the reload sees it.
try {
var xhr = new XMLHttpRequest();
xhr.open('POST', '/api/settings', false);
xhr.setRequestHeader('Content-Type', 'application/json');
xhr.send(JSON.stringify({ lang: l }));
} catch (e) { }
try { localStorage.setItem('thefeed_lang', l); } catch (e) { }
try { if (typeof IOS !== 'undefined' && IOS.setLang) IOS.setLang(l); } catch (e) { }
try { if (typeof Android !== 'undefined' && Android.setLang) Android.setLang(l); } catch (e) { }
location.reload();
}
</script>
<div class="app" id="app">
<!-- SIDEBAR -->
@@ -4043,9 +4088,21 @@
// native value first survives an embedded server rebinding to a
// new loopback port on each launch.
var lang = (function () {
// Same precedence as the modal-init script: server first (so a
// wiped thefeeddata/ also wipes the language), then native bridge,
// then localStorage as a final cache.
try {
var xhr = new XMLHttpRequest();
xhr.open('GET', '/api/settings', false);
xhr.send();
if (xhr.status === 200) {
var d = JSON.parse(xhr.responseText);
if (d.lang) return d.lang;
}
} catch (e) { }
try { if (typeof IOS !== 'undefined' && IOS.getLang) { var v = IOS.getLang(); if (v) return v; } } catch (e) { }
try { if (typeof Android !== 'undefined' && Android.getLang) { var v2 = Android.getLang(); if (v2) return v2; } } catch (e) { }
return localStorage.getItem('thefeed_lang') || 'fa';
return localStorage.getItem('thefeed_lang') || '';
})();
function t(k) { return (I18N[lang] && I18N[lang][k]) || I18N.en[k] || k }
function applyLang() {
@@ -4055,8 +4112,8 @@
document.querySelectorAll('[data-i18n]').forEach(function (el) { el.textContent = t(el.dataset.i18n) });
document.querySelectorAll('[data-i18n-ph]').forEach(function (el) { el.placeholder = t(el.dataset.i18nPh) });
document.querySelectorAll('[data-i18n-title]').forEach(function (el) { el.title = t(el.dataset.i18nTitle) });
document.getElementById('langFa').classList.toggle('active-lang', lang === 'fa');
document.getElementById('langEn').classList.toggle('active-lang', lang === 'en');
var lf = document.getElementById('langFa'); if (lf) lf.classList.toggle('active-lang', lang === 'fa');
var le = document.getElementById('langEn'); if (le) le.classList.toggle('active-lang', lang === 'en');
document.getElementById('sendInput').style.direction = isRtl ? 'rtl' : 'ltr';
applyThemeButtons();
// Re-render dynamic content
@@ -5351,9 +5408,15 @@
// pre-named profile instead of "thefeed.example.com" pulled
// from the domain. Capped at 32 chars on this side; the
// import side enforces the same cap defensively.
// Drop the default ":53" suffix from each resolver — the parser
// assumes 53 when no port is given, so omitting it shortens the
// URI considerably. Custom ports are kept and stay un-encoded.
var compact = resolvers.map(function (r) {
return r.replace(/:53$/, '');
}).join(',');
var uri = 'thefeed://' + encodeURIComponent(p.config.domain)
+ '/' + encodeURIComponent(p.config.key)
+ '?r=' + encodeURIComponent(resolvers.join(','));
+ '?r=' + encodeURIComponent(compact).replace(/%3A/g, ':');
var nick = (p.nickname || '').trim().slice(0, 32);
if (nick && nick !== p.config.domain) {
uri += '&n=' + encodeURIComponent(nick);
+1 -2
View File
@@ -2,12 +2,11 @@ import SwiftUI
struct ContentView: View {
@EnvironmentObject var server: ServerController
@AppStorage("tf.lang") private var lang: String = ""
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if !lang.isEmpty && server.port > 0 {
if server.port > 0 {
WebView(url: URL(string: "http://127.0.0.1:\(server.port)")!)
.ignoresSafeArea()
} else if let err = server.lastError {
-68
View File
@@ -1,68 +0,0 @@
import SwiftUI
/// First-launch language selection. Stored in UserDefaults under "tf.lang".
struct LanguagePickerView: View {
let onPick: (String) -> Void
var body: some View {
ZStack {
Color(red: 0.07, green: 0.09, blue: 0.13).ignoresSafeArea()
VStack(spacing: 40) {
Spacer()
VStack(spacing: 8) {
Text("TheFeed")
.font(.system(size: 40, weight: .bold, design: .rounded))
.foregroundColor(.white)
Text("Choose your language · زبان خود را انتخاب کنید")
.font(.callout)
.foregroundColor(.white.opacity(0.6))
.multilineTextAlignment(.center)
.padding(.horizontal, 24)
}
VStack(spacing: 16) {
LanguageButton(label: "English", subtitle: "Continue in English") {
onPick("en")
}
LanguageButton(label: "فارسی", subtitle: "ادامه به زبان فارسی") {
onPick("fa")
}
}
.padding(.horizontal, 32)
Spacer()
}
}
}
}
private struct LanguageButton: View {
let label: String
let subtitle: String
let action: () -> Void
var body: some View {
Button(action: action) {
VStack(spacing: 4) {
Text(label)
.font(.system(size: 22, weight: .semibold))
.foregroundColor(.white)
Text(subtitle)
.font(.footnote)
.foregroundColor(.white.opacity(0.7))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 18)
.background(
RoundedRectangle(cornerRadius: 16)
.fill(Color.white.opacity(0.08))
.overlay(
RoundedRectangle(cornerRadius: 16)
.stroke(Color.white.opacity(0.15), lineWidth: 1)
)
)
}
}
}
-9
View File
@@ -3,7 +3,6 @@ import SwiftUI
@main
struct ThefeedApp: App {
@StateObject private var server = ServerController()
@AppStorage("tf.lang") private var lang: String = ""
var body: some Scene {
WindowGroup {
@@ -11,14 +10,6 @@ struct ThefeedApp: App {
.environmentObject(server)
.onAppear { server.start() }
.onDisappear { server.stop() }
.fullScreenCover(isPresented: Binding(
get: { lang.isEmpty },
set: { _ in }
)) {
LanguagePickerView { picked in
lang = picked
}
}
}
}
}
+3 -4
View File
@@ -61,11 +61,10 @@ struct WebView: UIViewRepresentable {
"""
}
/// Reads the language picked at first launch. Falls back to "en"
/// for the brief window before the picker has been answered.
/// Returns the saved language ("" on first launch) so the WebView's
/// own language picker can detect first-launch and show its modal.
private func resolveLang() -> String {
let saved = UserDefaults.standard.string(forKey: "tf.lang") ?? ""
return saved.isEmpty ? "en" : saved
return UserDefaults.standard.string(forKey: "tf.lang") ?? ""
}
func updateUIView(_ view: WKWebView, context: Context) {