mirror of
https://github.com/619dev/Paperphone-plus.git
synced 2026-09-03 07:18:05 +08:00
同步 Android 2.3.3 前端更新
This commit is contained in:
4
client/package-lock.json
generated
4
client/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "paperphone-plus-client",
|
||||
"private": true,
|
||||
"version": "2.2.9",
|
||||
"version": "2.3.3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
@@ -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 />} />
|
||||
|
||||
13
client/src/components/CallKeepAwake.tsx
Normal file
13
client/src/components/CallKeepAwake.tsx
Normal 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
|
||||
}
|
||||
47
client/src/hooks/useKeepAwake.ts
Normal file
47
client/src/hooks/useKeepAwake.ts
Normal 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])
|
||||
}
|
||||
@@ -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 2–5, Pixel 6a–9a, 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 6–10/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
|
||||
═══════════════════════════════════════════════════════════════ */
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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"}
|
||||
Reference in New Issue
Block a user