同步 Android 2.3.3 前端更新

This commit is contained in:
619dev
2026-08-07 14:36:20 +08:00
parent 12bc69c70e
commit e5eb095e35
9 changed files with 133 additions and 44 deletions

View File

@@ -1,12 +1,12 @@
{
"name": "paperphone-plus-client",
"version": "2.2.9",
"version": "2.3.3",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "paperphone-plus-client",
"version": "2.2.9",
"version": "2.3.3",
"dependencies": {
"crystals-kyber-js": "^2.7.0",
"jsqr": "^1.4.0",

View File

@@ -1,7 +1,7 @@
{
"name": "paperphone-plus-client",
"private": true,
"version": "2.2.9",
"version": "2.3.3",
"type": "module",
"scripts": {
"dev": "vite",

View File

@@ -19,6 +19,7 @@ import TermsOfUse from './pages/TermsOfUse'
import TabBar from './components/TabBar'
import CallOverlay from './components/CallOverlay'
import GroupCallOverlay from './components/GroupCallOverlay'
import CallKeepAwake from './components/CallKeepAwake'
import NotificationToast from './components/NotificationToast'
import { CallProvider } from './contexts/CallContext'
import { GroupCallProvider } from './contexts/GroupCallContext'
@@ -150,6 +151,7 @@ function ProtectedLayout() {
return (
<CallProvider>
<GroupCallProvider>
<CallKeepAwake />
<Routes>
<Route path="/chats" element={<Chats />} />
<Route path="/chat/:id" element={<Chat />} />

View File

@@ -0,0 +1,13 @@
import { useCallContext } from '../contexts/CallContext'
import { useGroupCallContext } from '../contexts/GroupCallContext'
import { useKeepAwake } from '../hooks/useKeepAwake'
/** Coordinates browser screen wake state across direct and group calls. */
export default function CallKeepAwake() {
const { callState } = useCallContext()
const { status: groupCallStatus } = useGroupCallContext()
const isCallActive = (callState !== 'idle' && callState !== 'error') || groupCallStatus !== 'idle'
useKeepAwake(isCallActive)
return null
}

View File

@@ -0,0 +1,47 @@
import { useEffect } from 'react'
type WakeLockSentinel = EventTarget & {
released: boolean
release(): Promise<void>
}
type WakeLockNavigator = Navigator & {
wakeLock?: {
request(type: 'screen'): Promise<WakeLockSentinel>
}
}
/** Keep the screen awake while an activity such as a call or recording is active. */
export function useKeepAwake(enabled: boolean) {
useEffect(() => {
let sentinel: WakeLockSentinel | null = null
let disposed = false
const request = async () => {
if (!enabled || disposed || document.visibilityState !== 'visible') return
try {
const wakeLock = (navigator as WakeLockNavigator).wakeLock
if (!wakeLock) return
sentinel = await wakeLock.request('screen')
} catch (error) {
console.warn('[KeepAwake] Unable to acquire screen wake lock:', error)
}
}
const handleVisibilityChange = () => {
if (document.visibilityState === 'visible' && (!sentinel || sentinel.released)) {
void request()
}
}
void request()
document.addEventListener('visibilitychange', handleVisibilityChange)
return () => {
disposed = true
document.removeEventListener('visibilitychange', handleVisibilityChange)
void sentinel?.release().catch(() => {})
sentinel = null
}
}, [enabled])
}

View File

@@ -59,10 +59,11 @@
--header-h: 56px;
--tabbar-h: 52px;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
--safe-left: env(safe-area-inset-left, 0px);
--safe-right: env(safe-area-inset-right, 0px);
/* Prefer the larger inset exposed by the browser or a native WebView. */
--safe-top: max(env(safe-area-inset-top, 0px), var(--safe-area-inset-top, 0px));
--safe-bottom: max(env(safe-area-inset-bottom, 0px), var(--safe-area-inset-bottom, 0px));
--safe-left: max(env(safe-area-inset-left, 0px), var(--safe-area-inset-left, 0px));
--safe-right: max(env(safe-area-inset-right, 0px), var(--safe-area-inset-right, 0px));
/* ── Motion (iOS spring curves) ─────────────────────────── */
--ease: cubic-bezier(0.2, 0.8, 0.2, 1);
@@ -213,7 +214,8 @@ body {
min-height: calc(var(--header-h) + var(--safe-top));
display: flex;
align-items: center;
padding: 0 16px;
padding-left: max(16px, var(--safe-left));
padding-right: max(16px, var(--safe-right));
padding-top: var(--safe-top);
border-bottom: 0.5px solid var(--glass-border-bottom);
z-index: 10;
@@ -260,7 +262,10 @@ body {
border-bottom: none;
border-left: none;
border-right: none;
margin: 0 12px calc(8px + var(--safe-bottom));
margin-top: 0;
margin-right: max(12px, var(--safe-right));
margin-bottom: calc(8px + var(--safe-bottom));
margin-left: max(12px, var(--safe-left));
border-radius: var(--radius-xl);
overflow: hidden;
flex-shrink: 0;
@@ -1414,7 +1419,7 @@ textarea.input {
}
/* ═══════════════════════════════════════════════════════════════
iPhone 12+ Responsive Adaptations
Compact Phone Responsive Adaptations (iPhone + Google Pixel)
iPhone 12 mini: 375 × 812pt (safe-top: 47pt, safe-bottom: 34pt)
iPhone 12/12 Pro: 390 × 844pt (safe-top: 47pt, safe-bottom: 34pt)
iPhone 13/13 Pro/14: 390 × 844pt (safe-top: 47pt, safe-bottom: 34pt)
@@ -1427,7 +1432,7 @@ textarea.input {
iPhone 16 Pro Max: 440 × 956pt (safe-top: 59pt Dynamic Island, safe-bottom: 34pt)
═══════════════════════════════════════════════════════════════ */
/* ── iPhone 12 mini (375pt wide, smallest notch iPhone) ───── */
/* ── Compact phones (Pixel 25, Pixel 6a9a, iPhone mini) ─── */
@media screen and (max-width: 375px) {
:root {
--header-h: 48px;
@@ -1439,7 +1444,8 @@ textarea.input {
}
.page-header {
padding: 0 12px;
padding-left: max(12px, var(--safe-left));
padding-right: max(12px, var(--safe-right));
padding-top: var(--safe-top);
}
@@ -1469,7 +1475,9 @@ textarea.input {
}
.tabbar {
margin: 0 8px calc(6px + var(--safe-bottom));
margin-right: max(8px, var(--safe-right));
margin-bottom: calc(6px + var(--safe-bottom));
margin-left: max(8px, var(--safe-left));
}
.tabbar-item {
@@ -1561,7 +1569,7 @@ textarea.input {
}
}
/* ── iPhone 12/13/14 standard (390pt wide) ────────────────── */
/* ── Standard compact phones (390pt wide) ────────────────── */
@media screen and (min-width: 376px) and (max-width: 393px) {
:root {
--header-h: 52px;
@@ -1569,16 +1577,19 @@ textarea.input {
}
.page-header {
padding: 0 14px;
padding-left: max(14px, var(--safe-left));
padding-right: max(14px, var(--safe-right));
padding-top: var(--safe-top);
}
.tabbar {
margin: 0 10px calc(6px + var(--safe-bottom));
margin-right: max(10px, var(--safe-right));
margin-bottom: calc(6px + var(--safe-bottom));
margin-left: max(10px, var(--safe-left));
}
}
/* ── iPhone 14 Pro / 15 / 16 (393pt, Dynamic Island) ─────── */
/* ── Standard phones (Pixel 610/Pro, recent iPhones) ─────── */
@media screen and (min-width: 393px) and (max-width: 430px) {
:root {
--header-h: 54px;
@@ -1586,7 +1597,7 @@ textarea.input {
}
}
/* ── iPhone Pro Max / Plus models (428-440pt) ─────────────── */
/* ── Large phones and foldable cover screens ──────────────── */
@media screen and (min-width: 428px) and (max-width: 480px) {
:root {
--header-h: 56px;
@@ -1776,25 +1787,6 @@ textarea.input {
}
}
/* ═══════════════════════════════════════════════════════════════
Notch / Dynamic Island — Prevent Content Overlap
Uses @supports to only apply when env() is available (iOS 11+)
═══════════════════════════════════════════════════════════════ */
@supports (padding-top: env(safe-area-inset-top)) {
/* Ensure fixed/absolute positioned elements don't slip behind notch */
.page-header {
padding-top: env(safe-area-inset-top, 0px);
}
.chat-input-bar {
padding-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
}
.tabbar {
margin-bottom: calc(8px + env(safe-area-inset-bottom, 0px));
}
}
/* ═══════════════════════════════════════════════════════════════
Login Page — Privacy Link
═══════════════════════════════════════════════════════════════ */

View File

@@ -15,6 +15,7 @@ import StickerMedia from '../components/StickerMedia'
import { decodeMessagePayload, encodeMessagePayload, type ReplyReference } from '../utils/messagePayload'
import { cacheSticker, cacheStickerPack } from '../utils/stickerCache'
import { readOfflineData, writeOfflineData } from '../utils/offlineCache'
import { useKeepAwake } from '../hooks/useKeepAwake'
// Auto-delete options (seconds)
const AUTO_DELETE_OPTIONS = [
@@ -40,6 +41,7 @@ const EMOJI_CATEGORIES = [
const RECENT_EMOJI_KEY = 'pp_recent_emoji'
const STICKER_PACKS_CACHE_KEY = 'sticker-packs'
const STICKER_PACK_CACHE_PREFIX = 'sticker-pack:'
const MAX_VOICE_DURATION_SECONDS = 120
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B'
@@ -363,12 +365,14 @@ export default function Chat() {
const recChunksRef = useRef<Blob[]>([])
const recStartRef = useRef<number>(0)
const recIntervalRef = useRef<any>(null)
const recTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const mediaStreamRef = useRef<MediaStream | null>(null)
const recordVoiceModeRef = useRef<'normal'|'slow'|'fast'>('normal')
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const longPressOriginRef = useRef<{ x: number; y: number } | null>(null)
const suppressMessageClickRef = useRef(false)
recordVoiceModeRef.current = recordVoiceMode
useKeepAwake(isRecording)
const resizeInput = useCallback((element?: HTMLTextAreaElement | null) => {
const textarea = element || inputRef.current
@@ -383,6 +387,17 @@ export default function Chat() {
resizeInput()
}, [input, resizeInput])
useEffect(() => () => {
clearInterval(recIntervalRef.current)
if (recTimeoutRef.current) clearTimeout(recTimeoutRef.current)
const recorder = mediaRecRef.current
if (recorder && recorder.state !== 'inactive') {
recorder.onstop = null
recorder.stop()
}
mediaStreamRef.current?.getTracks().forEach(track => track.stop())
}, [])
const friend = friends.find(f => f.id === id)
const group = groups.find(g => g.id === id)
const chatName = isGroup ? (group?.name || id) : (friend?.nickname || id)
@@ -880,7 +895,10 @@ export default function Chat() {
const srcBuffer = await audioCtx.decodeAudioData(arrayBuf)
const offline = new OfflineAudioContext(
srcBuffer.numberOfChannels,
Math.ceil(srcBuffer.length / rate),
Math.min(
Math.ceil(srcBuffer.length / rate),
srcBuffer.sampleRate * MAX_VOICE_DURATION_SECONDS,
),
srcBuffer.sampleRate,
)
const source = offline.createBufferSource()
@@ -912,8 +930,14 @@ export default function Chat() {
mediaRecRef.current = rec
setIsRecording(true)
recIntervalRef.current = setInterval(() => {
setRecordDuration(Math.floor((Date.now() - recStartRef.current) / 1000))
setRecordDuration(Math.min(
MAX_VOICE_DURATION_SECONDS,
Math.floor((Date.now() - recStartRef.current) / 1000),
))
}, 500)
recTimeoutRef.current = setTimeout(() => {
void stopVoice()
}, MAX_VOICE_DURATION_SECONDS * 1000)
} catch {
alert(t('chat.mic_failed'))
}
@@ -923,23 +947,33 @@ export default function Chat() {
const rec = mediaRecRef.current
if (!rec || rec.state === 'inactive') return
clearInterval(recIntervalRef.current)
if (recTimeoutRef.current) {
clearTimeout(recTimeoutRef.current)
recTimeoutRef.current = null
}
setIsRecording(false)
const duration = Math.round((Date.now() - recStartRef.current) / 1000)
const duration = Math.min(
MAX_VOICE_DURATION_SECONDS,
Math.round((Date.now() - recStartRef.current) / 1000),
)
const mode = recordVoiceModeRef.current
rec.stop()
rec.onstop = async () => {
const raw = new Blob(recChunksRef.current, { type: 'audio/webm' })
try {
const { blob, ext, mime, rateApplied } = await processVoiceBlob(raw, mode)
const file = new File([blob], `voice_${Date.now()}.${ext}`, { type: mime })
// 变速会改变时长:实际时长 = 原时长 / rate
const finalDuration = Math.max(1, Math.round(duration / rateApplied))
const finalDuration = Math.min(
MAX_VOICE_DURATION_SECONDS,
Math.max(1, Math.round(duration / rateApplied)),
)
const { url } = await uploadWithProgress(file, t('chat.uploading_voice'))
sendMessage(url, 'voice', { url, duration: finalDuration })
} catch {
alert(t('chat.upload_failed'))
}
}
rec.stop()
rec.stream.getTracks().forEach(t => t.stop())
if (mediaStreamRef.current) {
mediaStreamRef.current.getTracks().forEach(t => t.stop())
@@ -1188,6 +1222,7 @@ export default function Chat() {
</div>
<div style={{ fontSize: 22, fontWeight: 700, fontVariantNumeric: 'tabular-nums', color: 'var(--text-primary)' }}>
{Math.floor(recordDuration / 60).toString().padStart(2, '0')}:{(recordDuration % 60).toString().padStart(2, '0')}
<span style={{ fontSize: 14, fontWeight: 500, color: 'var(--text-muted)' }}> / 02:00</span>
</div>
<div style={{ fontSize: 13, color: 'var(--text-muted)' }}>{t('chat.recording')}</div>

View File

@@ -14,7 +14,7 @@ import { Camera, ChevronLeft, ChevronRight, Smartphone, Check, Copy, KeyRound, S
import { clearOfflineCache } from '../utils/offlineCache'
type SubView = null | 'password' | 'avatar' | '2fa' | 'sessions' | 'language' | 'fingerprint' | 'myqr' | 'proxy'
const APP_VERSION = '2.2.9'
const APP_VERSION = '2.3.3'
export default function Profile() {
const { t } = useI18n()

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/http.ts","./src/api/nativepush.ts","./src/api/onesignal.ts","./src/api/proxy-bridge.ts","./src/api/push.ts","./src/api/socket.ts","./src/api/sync.ts","./src/components/calloverlay.tsx","./src/components/groupcalloverlay.tsx","./src/components/notificationtoast.tsx","./src/components/qrcode.tsx","./src/components/stickermedia.tsx","./src/components/tabbar.tsx","./src/components/tgsplayer.tsx","./src/contexts/callcontext.tsx","./src/contexts/groupcallcontext.tsx","./src/crypto/groupcrypto.ts","./src/crypto/keystore.ts","./src/crypto/ratchet.ts","./src/hooks/useauth.ts","./src/hooks/useautodeletecleanup.ts","./src/hooks/usecall.ts","./src/hooks/usegroupcall.ts","./src/hooks/usei18n.ts","./src/hooks/usesocket.ts","./src/i18n/index.ts","./src/i18n/locales/de.ts","./src/i18n/locales/en.ts","./src/i18n/locales/es.ts","./src/i18n/locales/fr.ts","./src/i18n/locales/ja.ts","./src/i18n/locales/ko.ts","./src/i18n/locales/ru.ts","./src/i18n/locales/zh.ts","./src/pages/chat.tsx","./src/pages/chats.tsx","./src/pages/contacts.tsx","./src/pages/discover.tsx","./src/pages/groupinfo.tsx","./src/pages/login.tsx","./src/pages/moments.tsx","./src/pages/privacypolicy.tsx","./src/pages/profile.tsx","./src/pages/termsofuse.tsx","./src/pages/timeline.tsx","./src/pages/userprofile.tsx","./src/store/index.ts","./src/store/notificationstore.ts","./src/utils/messagepayload.ts","./src/utils/notification.ts","./src/utils/offlinecache.ts","./src/utils/platform.ts","./src/utils/session.ts","./src/utils/stickercache.ts"],"version":"5.7.3"}
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/http.ts","./src/api/nativepush.ts","./src/api/onesignal.ts","./src/api/proxy-bridge.ts","./src/api/push.ts","./src/api/socket.ts","./src/api/sync.ts","./src/components/callkeepawake.tsx","./src/components/calloverlay.tsx","./src/components/groupcalloverlay.tsx","./src/components/notificationtoast.tsx","./src/components/qrcode.tsx","./src/components/stickermedia.tsx","./src/components/tabbar.tsx","./src/components/tgsplayer.tsx","./src/contexts/callcontext.tsx","./src/contexts/groupcallcontext.tsx","./src/crypto/groupcrypto.ts","./src/crypto/keystore.ts","./src/crypto/ratchet.ts","./src/hooks/useauth.ts","./src/hooks/useautodeletecleanup.ts","./src/hooks/usecall.ts","./src/hooks/usegroupcall.ts","./src/hooks/usei18n.ts","./src/hooks/usekeepawake.ts","./src/hooks/usesocket.ts","./src/i18n/index.ts","./src/i18n/locales/de.ts","./src/i18n/locales/en.ts","./src/i18n/locales/es.ts","./src/i18n/locales/fr.ts","./src/i18n/locales/ja.ts","./src/i18n/locales/ko.ts","./src/i18n/locales/ru.ts","./src/i18n/locales/zh.ts","./src/pages/chat.tsx","./src/pages/chats.tsx","./src/pages/contacts.tsx","./src/pages/discover.tsx","./src/pages/groupinfo.tsx","./src/pages/login.tsx","./src/pages/moments.tsx","./src/pages/privacypolicy.tsx","./src/pages/profile.tsx","./src/pages/termsofuse.tsx","./src/pages/timeline.tsx","./src/pages/userprofile.tsx","./src/store/index.ts","./src/store/notificationstore.ts","./src/utils/messagepayload.ts","./src/utils/notification.ts","./src/utils/offlinecache.ts","./src/utils/platform.ts","./src/utils/session.ts","./src/utils/stickercache.ts"],"version":"5.7.3"}