路由从 hash 换成 History API(pushState/replaceState/popstate),不再依赖 location.hash

HashNavigation 改名 Navigation(src/lib/navigation.js),接口不变,内部改用真实路径:
parseUrlHash/composeUrlHash 改名 parseUrl/composeUrl;navigateTo/navigateReplace 改调
pushState/replaceState 后手动派发 popstate(pushState/replaceState 不会像赋值 location.hash
那样自动触发事件,这里复用 navigateReplace 早就在用的同一个手动 dispatch 手法);goBack()
不用改,history.back() 本来就是真实浏览器历史机制。新增 Navigation.currentUrl() 替代调用方
原本直接读的 window.location.hash(这个类从来没包装过这个读取)。

一处需要专门处理的边界情况:当 path[0] 是空字符串("不选中侧栏视图、只开一个会话"这个仓库里
的合法状态)时,拼出的真实路径开头会变成双斜杠,被浏览器当协议相对地址解析、试图跳到一个叫
会话id的host,pushState 直接抛 SecurityError。composeUrl/parseUrl 内部用占位 token "_" 处理
这种情况,对外的调用方(19个文件)完全无感。

tinode-web.jsx 的 handleHashRoute 改名 handleRouteChange,监听从 hashchange 换成 popstate;
service-worker.js 的推送通知点击跳转手动改写成同样的路径格式(这个文件在打包之外单独执行,
没法 import Navigation)。index.html/index-dev.html 加了 <base href="/">——所有静态资源引用
都是相对路径,hash 路由下页面路径永远是 /,换真实路径后深层地址刷新会按当前目录解析出错误
资源地址导致白屏,这一行是标准修法。nginx.conf 的 try_files 兜底规则本来就适用两种路由,只
更新了注释。

实机验证:地址栏不再有 #;点击会话/设置等各类视图正常跳转;浏览器前进后退和应用内"返回"
按钮都正常;收藏页内部的类型 tab 切换(navigateReplace)确认不产生新的历史记录;在会话页/
个人中心页刷新、直接用完整深层路径打开新标签页都能正确恢复到位,资源正常加载不白屏。

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-09-07 21:00:39 +08:00
co-authored by Claude Sonnet 5
parent 97a6b88d59
commit 4e8f45d7d8
46 changed files with 398 additions and 330 deletions
+4 -4
View File
@@ -1,6 +1,6 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { BASE_APP_NAME } from '../config.js';
import {
resolveTenant, submitTenantApplication, submitTenantEntryFeedback, uploadPublicFile,
@@ -16,8 +16,8 @@ const CREDIT_CODE_RE = /^[0-9A-Z]{18}$/;
const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// params is optional and additive only -- every existing call site that passes just `view` keeps
// producing the exact same bare hash as the old `HashNavigation.navigateTo(view)` did.
const navigate = (view, params) => HashNavigation.navigateTo(HashNavigation.composeUrlHash([view], params || {}));
// producing the exact same bare hash as the old `Navigation.navigateTo(view)` did.
const navigate = (view, params) => Navigation.navigateTo(Navigation.composeUrl([view], params || {}));
function Icon({children, className = ''}) {
return <i className={'material-icons ' + className} aria-hidden="true">{children}</i>;
@@ -32,7 +32,7 @@ function AppMark({tenantName}) {
function Header({title, back = 'tenant'}) {
return <header className="auth-header">
<button type="button" className="auth-icon-button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回">
<button type="button" className="auth-icon-button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回">
<Icon>arrow_back_ios_new</Icon>
</button>
<h1>{title}</h1>
+2 -2
View File
@@ -1,8 +1,8 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
const navigate = view => HashNavigation.navigateTo(view);
const navigate = view => Navigation.navigateTo(view);
export const CALL_EXPERIENCE_VIEWS = [
'call-history', 'call-incoming', 'audio-call-ringing', 'audio-call-active',
+12 -12
View File
@@ -2,7 +2,7 @@ import React from 'react';
import QRCode from 'qrcodejs';
import { pinyin } from 'pinyin-pro';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import {
getFriends, getFriendRequests, createFriendRequest, acceptFriendRequest, rejectFriendRequest,
deleteFriend, updateFriendRemark, searchUsers, getUserProfile, createMyQRCode, getBlocks,
@@ -14,9 +14,9 @@ import { readCachedData, writeCachedData, useCachedData } from '../lib/use-cache
// Carries an optional `uid` (and anything else already in the hash) across chapter-5 navigation --
// e.g. address-book -> friend-profile -> friend-settings all need to know which person this is.
const navigate = (view, uid) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = uid ? HashNavigation.addUrlParam(hash, 'uid', uid) : HashNavigation.removeUrlParam(hash, 'uid');
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
hash = uid ? Navigation.addUrlParam(hash, 'uid', uid) : Navigation.removeUrlParam(hash, 'uid');
Navigation.navigateTo(hash);
};
export const CONTACT_MANAGEMENT_VIEWS = [
@@ -44,7 +44,7 @@ function alphaInitial(name) {
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'messages-home', backUid, right, onRight}) {
return <header className="people-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backUid); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
return <header className="people-header"><button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back, backUid); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1>{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}</header>;
}
// `person.avatar_file_id` (real API field) is loaded lazily via a cached authenticated fetch --
@@ -589,8 +589,8 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
};
return <div className="people-page"><Header title="聊天设置" back="friend-profile" backUid={uid} /><main className="people-settings">
<section className="people-settings-profile"><Avatar person={profile} /><strong>{(profile && profile.display_name) || ''}</strong></section>
<section><button type="button" onClick={() => HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'history-search'), 'topic', uid))}>
<section><button type="button" onClick={() => Navigation.navigateTo(
Navigation.addUrlParam(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'history-search'), 'topic', uid))}>
<span>查看聊天记录</span><Icon>chevron_right</Icon>
</button>
<div><span>消息传输方式</span><small>已启动加密传输</small></div></section>
@@ -622,11 +622,11 @@ function Settings({uid, initialBlocked, pinBlocked, onToast}) {
// The local navigate(view, uid) helper only carries a single `uid` param -- the report
// flow (security-support-view.jsx's ReportReasons/ReportDetails) needs target_type/
// target_id/target_name, so build the hash directly here rather than through it.
let hash = HashNavigation.setUrlSidePanel(window.location.hash, 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'user');
hash = HashNavigation.addUrlParam(hash, 'target_id', uid);
hash = HashNavigation.addUrlParam(hash, 'target_name', (profile && profile.display_name) || uid);
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), 'abuse-report-reasons');
hash = Navigation.addUrlParam(hash, 'target_type', 'user');
hash = Navigation.addUrlParam(hash, 'target_id', uid);
hash = Navigation.addUrlParam(hash, 'target_name', (profile && profile.display_name) || uid);
Navigation.navigateTo(hash);
}}><span>投诉与举报</span><Icon>chevron_right</Icon></button>
</section>
{blocked ? null : <button type="button" className="people-danger" onClick={() => navigate('friend-delete-confirm', uid)}>删除好友</button>}
+9 -9
View File
@@ -4,7 +4,7 @@ import { Drafty, Tinode } from 'tinode-sdk';
import AudioPlayer from '../widgets/audio-player.jsx';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { bytesToHumanSize, secondsToTime, shortDateFormat } from '../lib/strformat.js';
import {
getFavorites, getFavorite, batchDeleteFavorites, forwardFavorites, createFileDownloader,
@@ -40,15 +40,15 @@ function resolveSourceName(topic, onDone) {
}
// Generic multi-param navigate -- content-library-view.jsx's original helper was a bare
// `HashNavigation.navigateTo(view)`, replacing the whole hash and dropping any existing params on
// `Navigation.navigateTo(view)`, replacing the whole hash and dropping any existing params on
// every internal tab/link click; nothing in this file could carry a topic/favoriteId through its own
// navigation even if a caller threaded one in from outside.
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
Navigation.navigateTo(hash);
};
// Same as navigate() above but for switching this page's own type tabs (Favorites' 全部/图片/.../链接,
@@ -56,7 +56,7 @@ const navigate = (view, params) => {
// which page the user is on, so they shouldn't push a browser-history entry the way a real navigate()
// does. Previously every tab click called navigate(), so N tab switches pushed N history entries and
// the system back button just cycled through past tabs instead of leaving the page.
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view));
export const CONTENT_LIBRARY_VIEWS = [
'favorites', 'favorites-mixed', 'favorites-files', 'favorites-text', 'favorites-voice',
@@ -68,7 +68,7 @@ export const CONTENT_LIBRARY_VIEWS = [
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back = 'messages-home', backParams, right, onRight}) {
return <header className="library-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back, backParams); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back, backParams); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
</header>;
@@ -695,11 +695,11 @@ function History({view, hashParams, myUserId, onToast}) {
}).catch(() => {});
});
}, [items]);
// Mirrors tinode-web.jsx's own handleTopicSelected exactly (HashNavigation.setUrlTopic('', topicName)
// Mirrors tinode-web.jsx's own handleTopicSelected exactly (Navigation.setUrlTopic('', topicName)
// -- an empty base hash, not the current one, per its "Clear newTopicParams after use" comment) so
// this lands the same way a normal chat-list click does, then layers the jump target on as a param;
// navigate()'s own helper only ever touches path[0]+params, never path[1] (the actual topic slot).
const jump = seq => HashNavigation.navigateTo(HashNavigation.addUrlParam(HashNavigation.setUrlTopic('', topic), 'jumpseq', seq));
const jump = seq => Navigation.navigateTo(Navigation.addUrlParam(Navigation.setUrlTopic('', topic), 'jumpseq', seq));
const nameOf = from => {
if (!from) return '';
if (from === myUserId || ('usr' + from) === myUserId) return '我';
+2 -2
View File
@@ -1,8 +1,8 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
const navigate = view => HashNavigation.navigateTo(view);
const navigate = view => Navigation.navigateTo(view);
const VIEW_CONFIG = {
'chat-inbound-request': {relationship: 'pending_inbound', variant: 'inbound-request'},
+18 -18
View File
@@ -2,7 +2,7 @@ import React from 'react';
import QRCode from 'qrcodejs';
import { pinyin } from 'pinyin-pro';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import {
getGroups, createGroup, getGroup, updateGroup, getGroupSettings, updateGroupMemberSettings,
updateMyGroupProfile, getMyGroupProfile, leaveGroup, getGroupMembers, createGroupQRCode, getGroupJoinRequests,
@@ -17,9 +17,9 @@ import { readCachedData, writeCachedData, useDebouncedValue } from '../lib/use-c
// Carries the group `topic` (and anything else already in the hash) across navigation, the same way
// contact-management-view.jsx threads `uid` -- these pages all need to know *which* group.
const navigate = (view, topic) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
hash = topic ? Navigation.addUrlParam(hash, 'topic', topic) : Navigation.removeUrlParam(hash, 'topic');
Navigation.navigateTo(hash);
};
// 'group-member-profile' is contact-management-view.jsx's Profile component (Ch.5) reused as-is for
@@ -45,8 +45,8 @@ const navigateToProfile = (topic, userId, member) => {
...(member.gender ? {gender: member.gender} : {})
});
}
HashNavigation.navigateTo(
HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel(window.location.hash, 'group-member-profile'), 'uid', userId));
Navigation.navigateTo(
Navigation.addUrlParam(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'group-member-profile'), 'uid', userId));
// 群场景下真正该用的是 GET /groups/{topic}/members/{user_id}/profile——群上下文里权限判断更
// 宽松,实测对方哪怕不是好友也能拿到 {capabilities, relationship}(Profile 组件目标页读的
// GET /users/{id}/profile 对非好友是 403,只能回退用种子)。异步补种:请求回来后如果用户还
@@ -140,7 +140,7 @@ function Icon({children}) { return <i className="material-icons" aria-hidden="tr
// navigate 辅助函数只会写 query 参数),这类情况传一个自己的回调进来。
function Header({title, back = 'group-list', backTopic, onBack, right, onRight}) {
return <header className="group-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) { if (onBack) onBack(); else navigate(back, backTopic); } }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<button type="button" onClick={() => { if (!Navigation.goBack()) { if (onBack) onBack(); else navigate(back, backTopic); } }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{right ? <button type="button" className="text" onClick={onRight}>{right}</button> : <span />}
</header>;
@@ -266,7 +266,7 @@ function JoinRequests({topic, fromChat, onToast}) {
// ambiguity above -- without it, returning from a click on the in-chat banner always landed on
// "group-settings" (closing the open conversation) instead of back to the chat itself.
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.setUrlSidePanel('', ''), topic));
}, [topic]);
const [items, setItems] = React.useState(() => (topic && readCachedData('group-join-requests:' + topic)) || null);
const [profiles, setProfiles] = React.useState({});
@@ -337,14 +337,14 @@ function Announcement({topic, myRole, fromChat, onToast}) {
const canEdit = myRole === 'owner' || myRole === 'admin';
// Coming from the announcement banner atop the chat itself (messages-view.jsx's
// handleShowAnnouncement) vs. from group-settings' "群公告" row land on the identical
// path[0]=group-announcement-detail hash -- `fromChat` is the only thing telling them apart, so
// path[0]=group-announcement-detail URL -- `fromChat` is the only thing telling them apart, so
// "back" can return to the chat (close the sidepanel) instead of always forcing group-settings.
// 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼 hash,所以 path[1] 是空的),返回应该
// 从聊天页横幅进来时(messages-view.jsx:1152 用空基准拼路径,所以 path[1] 是空的),返回应该
// 关掉侧栏回到那个会话。原来写的是 navigate('', topic)——navigate 只把 topic 写成 query 参数,
// path[1] 依旧为空,tinode-web 的 handleHashRoute 就把 topicSelected 清成 null,会话跟着一起关了。
// path[1] 依旧为空,tinode-web 的 handleRouteChange 就把 topicSelected 清成 null,会话跟着一起关了。
// 这里显式把 topic 写回 path[1]。
const backToChat = React.useCallback(() => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(HashNavigation.setUrlSidePanel('', ''), topic));
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.setUrlSidePanel('', ''), topic));
}, [topic]);
React.useEffect(() => {
if (!topic) return;
@@ -491,13 +491,13 @@ function GroupSettings({topic, myUserId, tinode, onToast}) {
{/* target_type='group' 这条链路早就是通的(tinode-web.jsx 的 handleReportTopic 就在用),
这里原来一直是个"暂未开放"的死桩。跟 contact-management-view.jsx 的好友举报入口一样,
举报页要的是 target_type/target_id/target_name 三个参数,本文件的 navigate 辅助函数
只会写 topic,所以直接拼 hash。 */}
只会写 topic,所以直接拼路径。 */}
<button type="button" onClick={() => {
let hash = HashNavigation.setUrlSidePanel('', 'abuse-report-reasons');
hash = HashNavigation.addUrlParam(hash, 'target_type', 'group');
hash = HashNavigation.addUrlParam(hash, 'target_id', topic);
hash = HashNavigation.addUrlParam(hash, 'target_name', (group && group.name) || topic);
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel('', 'abuse-report-reasons');
hash = Navigation.addUrlParam(hash, 'target_type', 'group');
hash = Navigation.addUrlParam(hash, 'target_id', topic);
hash = Navigation.addUrlParam(hash, 'target_name', (group && group.name) || topic);
Navigation.navigateTo(hash);
}}><span>投诉/举报</span><Icon>chevron_right</Icon></button>
</section>
<button type="button" className="group-danger" onClick={leave}>退出群组</button>
+2 -2
View File
@@ -10,7 +10,7 @@ import CheckBox from '../widgets/checkbox.jsx';
import ContactList from '../widgets/contact-list.jsx';
import ErrorPanel from '../widgets/error-panel.jsx';
import GroupManager from '../widgets/group-manager.jsx';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import MenuCancel from '../widgets/menu-cancel.jsx';
import PermissionsEditor from '../widgets/permissions-editor.jsx';
import ShowQRCode from '../widgets/show-qrcode.jsx';
@@ -455,7 +455,7 @@ class InfoView extends React.Component {
// system back button had to be pressed once per level ever visited, and each press could land back
// inside a sub-panel the user had already explicitly backed out of.
handleBackNavigate() {
if (HashNavigation.goBack()) {
if (Navigation.goBack()) {
return;
}
const args = (this.props.panel || 'info').split('/');
+6 -6
View File
@@ -1,7 +1,7 @@
import React from 'react';
import { Tinode } from 'tinode-sdk';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { BASE_APP_NAME } from '../config.js';
import { getBootstrap, getRealnameStatus, getSystemNotifications, markSystemNotificationRead } from '../lib/im-api.js';
import { makeImageUrl } from '../lib/blob-helpers.js';
@@ -15,9 +15,9 @@ import { topicDisplayName } from '../lib/utils.js';
// group-management-view.jsx 的同名辅助函数保持一致(不带 topic 时显式清掉,避免把上一个页面
// 残留的 topic 参数带进不相关的页面)。
const navigate = (view, topic) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
hash = topic ? HashNavigation.addUrlParam(hash, 'topic', topic) : HashNavigation.removeUrlParam(hash, 'topic');
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
hash = topic ? Navigation.addUrlParam(hash, 'topic', topic) : Navigation.removeUrlParam(hash, 'topic');
Navigation.navigateTo(hash);
};
// BottomNavigation's 消息<->工作台 switch is the app's root-level tab bar, not a navigation to a
@@ -27,7 +27,7 @@ const navigate = (view, topic) => {
// two bottom tabs) made the back button just cycle through past tab switches instead of leaving the
// app, exactly like a standard bottom-tab-bar app's back button should not have to unwind every past
// tab switch to back out.
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view));
// Was a hardcoded FREQUENT_CONTACTS mock (5 fake names, always clicking through to the same
// direct-message-view.jsx demo regardless of which avatar was tapped -- confirmed independently by
@@ -501,7 +501,7 @@ function SystemMessages({authenticated}) {
};
return <div className="system-messages-page">
<header className="messages-sub-header"><button onClick={() => { if (!HashNavigation.goBack()) navigate('messages-home'); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
<header className="messages-sub-header"><button onClick={() => { if (!Navigation.goBack()) navigate('messages-home'); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>系统消息</h1><span /></header>
<main className="system-notification-list">
{loading ? <p className="messages-empty"><Icon>hourglass_empty</Icon>加载中…</p> : null}
{error ? <p className="messages-empty"><Icon>error_outline</Icon>{error}</p> : null}
+2 -2
View File
@@ -1,8 +1,8 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
const navigate = view => HashNavigation.navigateTo(view);
const navigate = view => Navigation.navigateTo(view);
export const MESSAGE_COMPOSER_VIEWS = [
'compose-keyboard', 'mentions-select', 'mentions-complete', 'mentions-message',
+15 -15
View File
@@ -49,7 +49,7 @@ import { topicDisplayName } from '../lib/utils.js';
import { acceptGroupInvitation, checkURL, confirmRiskChallenge, createChatUploader, favoriteMessages, getBootstrap,
getConversationContext, getFavorites, getFriendRequests, getGroupJoinRequests, getGroupMembers, getLatestGroupAnnouncement,
getTopicDraft, recallMessage, updateTopicDraft } from '../lib/im-api.js';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { bytesToHumanSize, relativeDateFormat, shortDateFormat } from '../lib/strformat.js';
// Run timer with this frequency (ms) for checking notification queue.
@@ -725,7 +725,7 @@ class MessagesView extends React.Component {
.then(ctrl => {
if (ctrl.code == 303) {
// Redirect to another topic requested.
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', ctrl.params.topic));
Navigation.navigateTo(Navigation.setUrlTopic('', ctrl.params.topic));
return;
}
if (this.state.topic != ctrl.topic) {
@@ -1441,28 +1441,28 @@ class MessagesView extends React.Component {
}).catch(_ => {});
}
// Note: setUrlSidePanel only overwrites path[0] -- called with window.location.hash while a topic is
// Note: setUrlSidePanel only overwrites path[0] -- called with Navigation.currentUrl() while a topic is
// open (path[1] is the open topic), it leaves that segment in place, producing e.g.
// "#group-announcement-detail/grpXXX?topic=grpXXX". handleHashRoute then re-parses path[1] as a topic
// selector on every hashchange, fighting with the sidepanel that's trying to open and leaving the
// whole view blank. Build the target hash from an empty path instead of the current one.
// "/group-announcement-detail/grpXXX?topic=grpXXX". handleRouteChange then re-parses path[1] as a topic
// selector on every route change, fighting with the sidepanel that's trying to open and leaving the
// whole view blank. Build the target URL from an empty path instead of the current one.
handleShowAnnouncement(e) {
e.preventDefault();
// `from=chat` lets Announcement (group-management-view.jsx) tell this entry point apart from
// the one via group-settings -- both land on the identical path[0]/topic hash otherwise, so
// without it the "back" button can't know whether to return to group-settings or to this chat.
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-announcement-detail'), 'topic', this.state.topic);
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
HashNavigation.navigateTo(hash);
let hash = Navigation.addUrlParam(Navigation.setUrlSidePanel('', 'group-announcement-detail'), 'topic', this.state.topic);
hash = Navigation.addUrlParam(hash, 'from', 'chat');
Navigation.navigateTo(hash);
}
handleShowJoinRequests(e) {
e.preventDefault();
// `from=chat` mirrors handleShowAnnouncement above -- lets JoinRequests (group-management-view.jsx)
// return to this chat instead of always landing on group-governance.
let hash = HashNavigation.addUrlParam(HashNavigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
hash = HashNavigation.addUrlParam(hash, 'from', 'chat');
HashNavigation.navigateTo(hash);
let hash = Navigation.addUrlParam(Navigation.setUrlSidePanel('', 'group-join-requests'), 'topic', this.state.topic);
hash = Navigation.addUrlParam(hash, 'from', 'chat');
Navigation.navigateTo(hash);
}
handleSendFriendVerification(e) {
@@ -1471,8 +1471,8 @@ class MessagesView extends React.Component {
// current hash, since a topic is open) -- this one additionally never set `uid`, which the
// friend-verification page needs (contact-management-view.jsx's Verification reads hashParams.uid)
// to know who the verification is for; for a P2P topic that's simply the topic name itself.
HashNavigation.navigateTo(HashNavigation.addUrlParam(
HashNavigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
Navigation.navigateTo(Navigation.addUrlParam(
Navigation.setUrlSidePanel('', 'friend-verification'), 'uid', this.state.topic));
}
// 6.3 加入群聊.png:被邀请人在跟邀请人的会话里收到一张"邀请你加入群聊……立即加入"的卡片。
@@ -2239,7 +2239,7 @@ class MessagesView extends React.Component {
if (!topic || !seq || isNaN(seq)) {
return;
}
HashNavigation.navigateTo(HashNavigation.removeUrlParam(window.location.hash, 'jumpseq'));
Navigation.navigateTo(Navigation.removeUrlParam(Navigation.currentUrl(), 'jumpseq'));
const jump = () => window.setTimeout(() => this.handleQuoteClick(seq), 150);
if (topic.findMessage(seq)) {
jump();
+6 -6
View File
@@ -9,7 +9,7 @@ import NewTopicById from '../widgets/new-topic-by-id.jsx';
import NewTopicGroup from '../widgets/new-topic-group.jsx';
import SearchContacts from '../widgets/search-contacts.jsx';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { asEmail, asPhone, theCard } from '../lib/utils.js';
const messages = defineMessages({
@@ -34,7 +34,7 @@ class NewTopicView extends React.Component {
constructor(props) {
super(props);
const urlParams = HashNavigation.parseUrlHash(window.location.hash).params;
const urlParams = Navigation.parseUrl(Navigation.currentUrl()).params;
const initialTab = urlParams['tab'] || 'find';
const initialQuery = initialTab == 'find' ? (urlParams['q'] || null) : null;
@@ -60,7 +60,7 @@ class NewTopicView extends React.Component {
// page -- navigateTo() pushed a history entry per click purely to keep the `tab` url param in
// sync, so repeatedly switching tabs made the back button cycle through past tabs instead of
// leaving this page. navigateReplace() keeps the param in sync without that side effect.
HashNavigation.navigateReplace(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
Navigation.navigateReplace(Navigation.addUrlParam(Navigation.currentUrl(), 'tab', e.currentTarget.dataset.id));
this.setState({tabSelected: e.currentTarget.dataset.id});
}
@@ -91,19 +91,19 @@ class NewTopicView extends React.Component {
handleSearchResultSelected(topicName) {
if (this.state.tabSelected == 'find') {
HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));
Navigation.navigateTo(Navigation.removeUrlParams(Navigation.currentUrl(), ['tab', 'q']));
this.props.onCreateTopic(topicName);
}
}
handleNewGroupSubmit(name, description, dataUrl, priv, tags, isChannel) {
HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));
Navigation.navigateTo(Navigation.removeUrlParams(Navigation.currentUrl(), ['tab', 'q']));
this.props.onCreateTopic(undefined,
{public: theCard(name, dataUrl, null, description), private: priv, tags: tags}, isChannel);
}
handleGroupByID(topicName) {
HashNavigation.navigateTo(HashNavigation.removeUrlParams(window.location.hash, ['tab', 'q']));
Navigation.navigateTo(Navigation.removeUrlParams(Navigation.currentUrl(), ['tab', 'q']));
this.props.onCreateTopic(topicName);
}
+10 -10
View File
@@ -1,7 +1,7 @@
import React from 'react';
import QRCode from 'qrcodejs';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import { BASE_APP_NAME } from '../config.js';
import { PACKAGE_VERSION } from '../version.js';
@@ -12,22 +12,22 @@ import {
} from '../lib/im-api.js';
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
Navigation.navigateTo(hash);
};
// Same as navigate() but replaces the current history entry instead of pushing a new one -- for
// steps within a single flow (e.g. youth-mode off/on/verify) so "back" leaves the flow instead of
// re-walking its already-completed steps.
const navigateReplace = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
});
HashNavigation.navigateReplace(hash);
Navigation.navigateReplace(hash);
};
export const PERSONAL_SETTINGS_VIEWS = [
@@ -44,7 +44,7 @@ function Icon({children}) { return <i className="material-icons" aria-hidden="tr
function Header({title, back = 'personal-center', action, actionLabel, onAction, actionDisabled}) {
return <header className="personal-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{action ? <button type="button" className="text" onClick={onAction} disabled={actionDisabled}>{actionLabel || action}</button> : <span />}
</header>;
@@ -330,8 +330,8 @@ function SettingsList({onToast, onLogout, tinode, chatList, onDeleteAccount}) {
<MenuRow icon="verified_user" title="实名认证" value={realnameLabel} route="realname" />
{/* security-support-view.jsx 的 FraudAwareness 组件自己的注释就写着这应该是个
"always-reachable safety-info page",但此前没有任何页面真的链接过去——只能靠手改
hash 才能打开。这是 personal-settings-view.jsx 之外的一个路由,和本文件其它跳转
用的是同一套 hash 导航,可以直接指过去。 */}
地址栏才能打开。这是 personal-settings-view.jsx 之外的一个路由,和本文件其它跳转
用的是同一套导航机制,可以直接指过去。 */}
<MenuRow icon="shield" title="安全教育" route="fraud-risk-warning" />
</section>
<section><MenuRow icon="no_accounts" title="注销账号" onClick={() => setSheet('delete')} danger /></section>
+5 -5
View File
@@ -1,15 +1,15 @@
import React from 'react';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { uploadLargeFile, getAbuseReasons, submitAbuseReport, getFeedbackConfig, submitFeedback } from '../lib/im-api.js';
import { readCachedData, useCachedData } from '../lib/use-cached-data.js';
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
Navigation.navigateTo(hash);
};
export const SECURITY_SUPPORT_VIEWS = [
@@ -20,7 +20,7 @@ export const SECURITY_SUPPORT_VIEWS = [
function Icon({children}) { return <i className="material-icons" aria-hidden="true">{children}</i>; }
function Header({title, back}) {
return <header className="support-header"><button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
return <header className="support-header"><button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button><h1>{title}</h1><span /></header>;
}
function Toast({message, onDone}) {
+71 -71
View File
@@ -31,7 +31,7 @@ import { detectServerAddress, isLocalHost, isSecureConnection } from '../lib/hos
import LocalStorageUtil from '../lib/local-storage.js';
import { getTenantContext, resolveQRCode, createGroupJoinRequest, getBootstrap } from '../lib/im-api.js';
import { readCachedData, writeCachedData } from '../lib/use-cached-data.js';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { secondsToTime } from '../lib/strformat.js'
import { defaultWallpaper, theCard, topicDisplayName, updateFavicon } from '../lib/utils.js';
@@ -101,7 +101,7 @@ const messages = defineMessages({
}
});
// 全部合法的侧栏视图名。抽成模块级常量而不是内联在 handleHashRoute 里,是因为初始化时的
// 全部合法的侧栏视图名。抽成模块级常量而不是内联在 handleRouteChange 里,是因为初始化时的
// "要不要保留 hash" 判断也需要同一份名单——那里原来写的是上游 Tinode 遗留的
// ['cred','reset','register'] 三个名字,本工程新增的上百个路由一个都不在里面,导致带任何新路由的
// 地址刷新/深链进入都会被 navigateTo('') 清空 hash 弹回首页。
@@ -231,7 +231,7 @@ class TinodeWeb extends React.Component {
this.state = this.getBlankState();
this.handleResize = this.handleResize.bind(this);
this.handleHashRoute = this.handleHashRoute.bind(this);
this.handleRouteChange = this.handleRouteChange.bind(this);
this.handleOnline = this.handleOnline.bind(this);
this.handleEscapeKey = this.handleEscapeKey.bind(this);
this.handleColorSchemeChange = this.handleColorSchemeChange.bind(this);
@@ -470,7 +470,7 @@ class TinodeWeb extends React.Component {
window.addEventListener('online', this.handleOnlineOn);
this.handleOnlineOff = _ => { this.handleOnline(false); }
window.addEventListener('offline', this.handleOnlineOff);
window.addEventListener('hashchange', this.handleHashRoute);
window.addEventListener('popstate', this.handleRouteChange);
window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', this.handleColorSchemeChange);
// Window/tab visible or invisible for pausing timers.
@@ -550,24 +550,24 @@ class TinodeWeb extends React.Component {
}
// Parse the hash navigation params.
const parsedNav = HashNavigation.parseUrlHash(window.location.hash);
const parsedNav = Navigation.parseUrl(Navigation.currentUrl());
// Maybe navigate to home screen.
if (!SIDE_PANEL_VIEWS.includes(parsedNav.path[0]) || !parsedNav.path[0]) {
// Save possible topic name.
this.setState({requestedTopic: parsedNav.path[1]});
const path = parsedNav.params && parsedNav.params.cred_done ?
HashNavigation.addUrlParam('', 'cred_done', parsedNav.params.cred_done):
Navigation.addUrlParam('', 'cred_done', parsedNav.params.cred_done):
'';
HashNavigation.navigateTo(path);
Navigation.navigateTo(path);
} else {
this.handleHashRoute();
this.handleRouteChange();
}
});
}
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
window.removeEventListener('hashchange', this.handleHashRoute);
window.removeEventListener('popstate', this.handleRouteChange);
window.removeEventListener('online', this.handleOnlineOn);
window.removeEventListener('offline', this.handleOnlineOff);
window.matchMedia('(prefers-color-scheme: dark)').removeEventListener('change', this.handleColorSchemeChange);
@@ -742,7 +742,7 @@ class TinodeWeb extends React.Component {
alertVisible: false
});
// Navigate to contacts or login view
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
Navigation.navigateTo(Navigation.setUrlTopic('', null));
}
}
@@ -766,9 +766,9 @@ class TinodeWeb extends React.Component {
}
}
// Handle for hash navigation (hashchange) event: update state.
handleHashRoute() {
const hash = HashNavigation.parseUrlHash(window.location.hash);
// Handler for route navigation (popstate) event: update state.
handleRouteChange() {
const hash = Navigation.parseUrl(Navigation.currentUrl());
// Browser back navigation after a successful login re-renders one of the pre-login views
// (most commonly 'login') while the underlying Tinode connection is still authenticated --
// hash routing and the WS session's lifecycle are independent, so nothing else here would
@@ -780,7 +780,7 @@ class TinodeWeb extends React.Component {
// untranslated "already authenticated (409)" for registration. Redirecting away before any of
// that code runs removes the only way to reach it.
if (hash.path && AUTH_FLOW_VIEWS.includes(hash.path[0]) && this.tinode.isAuthenticated()) {
HashNavigation.navigateTo('');
Navigation.navigateTo('');
return;
}
// Start with panel parameters.
@@ -804,13 +804,13 @@ class TinodeWeb extends React.Component {
// A syntactically valid topic name is not enough on its own: mobilePanel='topic-view' only
// ever pairs correctly with MessagesView actually being allowed to render, which requires
// !chapterOneActive (i.e. this.tinode.isAuthenticated()) -- see tinode-web.jsx's render().
// Without this check, opening (or being redirected/deep-linked to) a #/<topic> URL while
// Without this check, opening (or being redirected/deep-linked to) a `/<topic>` URL while
// logged out set mobilePanel to 'topic-view' anyway; on mobile that made *both* the
// SidepanelView gate (`mobilePanel=='sidepanel'`) and the MessagesView gate
// (`!chapterOneActive`) false at once, so neither rendered -- a permanently blank white
// screen (app-container empty, no thrown error) that a hash change alone couldn't recover
// from, only a full reload. Reproduced live: logging out, then setting location.hash to a
// real group's `#/<topic>` URL. Falling back to the same "invalid topic" branch already used
// screen (app-container empty, no thrown error) that a route change alone couldn't recover
// from, only a full reload. Reproduced live: logging out, then navigating to a real group's
// `/<topic>` URL. Falling back to the same "invalid topic" branch already used
// for a malformed name reuses a path already proven safe with topicName=null.
if (!Tinode.topicType(topicName) || !this.tinode.isAuthenticated()) {
// Clear invalid topic name and hide messages view on mobile.
@@ -1009,7 +1009,7 @@ class TinodeWeb extends React.Component {
doLogin(login, password, tmpToken, cred) {
if (this.tinode.isAuthenticated()) {
// Already logged in. Go to default screen.
HashNavigation.navigateTo('');
Navigation.navigateTo('');
return;
}
@@ -1017,7 +1017,7 @@ class TinodeWeb extends React.Component {
if (!(login && password) && !token) {
// No login credentials provided.
// Make sure we are on the login page.
HashNavigation.navigateTo('');
Navigation.navigateTo('');
this.setState({loginDisabled: false});
return;
}
@@ -1064,7 +1064,7 @@ class TinodeWeb extends React.Component {
if (token) {
// 存的 token 失效(过期/被撤销)——这属于"会话没了",回到登录入口是对的。
this.handleLogout();
HashNavigation.navigateTo('');
Navigation.navigateTo('');
}
// 用户手输账号密码失败(401 之类)时**不要**导航。原来这里无条件 navigateTo(''),
// 会把 sidePanelSelected 清成 '',render 里 '' 又落到 'tenant',于是密码打错一次就被
@@ -1085,12 +1085,12 @@ class TinodeWeb extends React.Component {
if (!Array.isArray(params.cred) || !params.cred.length) {
return false;
}
const parsed = HashNavigation.parseUrlHash(window.location.hash);
const parsed = Navigation.parseUrl(Navigation.currentUrl());
parsed.path[0] = 'cred';
parsed.params['method'] = params.cred[0];
parsed.params['token'] = params.token;
parsed.params['code'] = params.code;
HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params));
Navigation.navigateTo(Navigation.composeUrl(parsed.path, parsed.params));
return true;
}
@@ -1120,7 +1120,7 @@ class TinodeWeb extends React.Component {
// Only the failure branch of doLogin's promise chain reset this; a success never needed to
// since it navigates away immediately -- until the same Login form could be reached again
// afterwards (browser back) with this still stuck true, permanently disabling the submit
// button. handleHashRoute's redirect above now closes off that specific path, but resetting
// button. handleRouteChange's redirect above now closes off that specific path, but resetting
// it here too means this can't get stuck regardless of how the form ends up rendered again.
loginDisabled: false,
credMethod: undefined,
@@ -1142,7 +1142,7 @@ class TinodeWeb extends React.Component {
this.tinode.disconnect();
localStorage.removeItem('auth-token');
this.handleError(err.message, 'err');
HashNavigation.navigateTo('');
Navigation.navigateTo('');
}).finally(_ => {
this.setState({loadSpinnerVisible: false});
});
@@ -1151,17 +1151,17 @@ class TinodeWeb extends React.Component {
// actually see. Land on the workbench instead (matches the "工作台优先" home screen in the mocks).
//
// 但只在"真的刚走完登录流程"时才这么跳。这个方法在页面刷新触发自动登录时也会执行,原来无条件
// 改写 hash,等于把用户刷新前所在的页面冲掉——不管停在个人中心、群设置还是收藏,刷新后一律
// 被弹到工作台。现在先看当前 hash 是不是一个有效的、非登录流程的视图,是就原样保留。
const currentView = HashNavigation.parseUrlHash(window.location.hash).path[0];
// 改写路由,等于把用户刷新前所在的页面冲掉——不管停在个人中心、群设置还是收藏,刷新后一律
// 被弹到工作台。现在先看当前路由是不是一个有效的、非登录流程的视图,是就原样保留。
const currentView = Navigation.parseUrl(Navigation.currentUrl()).path[0];
const cameFromAuthFlow = !currentView || AUTH_FLOW_VIEWS.includes(currentView) ||
!SIDE_PANEL_VIEWS.includes(currentView);
let urlHash = cameFromAuthFlow ?
HashNavigation.setUrlSidePanel(window.location.hash, 'workbench') : window.location.hash;
Navigation.setUrlSidePanel(Navigation.currentUrl(), 'workbench') : Navigation.currentUrl();
if (goToTopic) {
urlHash = HashNavigation.setUrlTopic(urlHash, goToTopic);
urlHash = Navigation.setUrlTopic(urlHash, goToTopic);
}
HashNavigation.navigateTo(urlHash);
Navigation.navigateTo(urlHash);
}
tnMeMetaDesc(desc) {
@@ -1425,7 +1425,7 @@ class TinodeWeb extends React.Component {
topicSelectedAcs: this.tinode.getTopicAccessMode(topicName),
forwardMessage: null
});
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', topicName));
Navigation.navigateTo(Navigation.setUrlTopic('', topicName));
}
} else {
// Currently selected contact deleted
@@ -1440,7 +1440,7 @@ class TinodeWeb extends React.Component {
forwardMessage: null
});
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', null));
Navigation.navigateTo(Navigation.setUrlTopic('', null));
}
}
@@ -1457,8 +1457,8 @@ class TinodeWeb extends React.Component {
this.setState({
mobilePanel: 'sidepanel'
});
if (!HashNavigation.goBack()) {
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, null));
if (!Navigation.goBack()) {
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), null));
}
}
@@ -1563,7 +1563,7 @@ class TinodeWeb extends React.Component {
handleNewAccount() {
this.handleError();
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'register'));
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'register'));
}
// Actual registration of a new account.
@@ -1666,7 +1666,7 @@ class TinodeWeb extends React.Component {
handleSelectWallpapers() {
this.handleError();
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'wallpapers'));
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'wallpapers'));
}
applyWallpaperSettings(wallpaper, size, blur, effectiveSchema) {
@@ -1707,7 +1707,7 @@ class TinodeWeb extends React.Component {
handleSettings() {
this.handleError();
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(),
this.state.myUserId ? 'edit' : 'settings'));
}
@@ -1741,7 +1741,7 @@ class TinodeWeb extends React.Component {
secureConnection: secureConnection,
});
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, ''));
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), ''));
}
// Tenant was resolved via POST /v1/im/tenants/resolve (see auth-onboarding-view.jsx TenantEntry).
@@ -1776,13 +1776,13 @@ class TinodeWeb extends React.Component {
// User chose 'Archived chats'.
handleShowArchive() {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(),
this.state.myUserId ? 'archive' : ''));
}
// User viewes 'Blocked chats'.
handleShowBlocked() {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash,
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(),
this.state.myUserId ? 'blocked' : ''));
}
@@ -1838,7 +1838,7 @@ class TinodeWeb extends React.Component {
// User clicked Cancel button in Setting or Sign Up panel.
handleSidepanelCancel() {
const parsed = HashNavigation.parseUrlHash(window.location.hash);
const parsed = Navigation.parseUrl(Navigation.currentUrl());
let path = '';
if (['security','support','general','notif','acc_general'].includes(parsed.path[0])) {
path = 'edit';
@@ -1859,18 +1859,18 @@ class TinodeWeb extends React.Component {
delete parsed.params.scheme;
delete parsed.params.token;
}
HashNavigation.navigateTo(HashNavigation.composeUrlHash(parsed.path, parsed.params));
Navigation.navigateTo(Navigation.composeUrl(parsed.path, parsed.params));
this.setState({errorText: '', errorLevel: null});
}
// Sidepanel navigator. No need to bind to 'this'.
basicNavigator(hash) {
HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, hash));
Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), hash));
}
// Topic info navigator. No need to bind to 'this'.
infoNavigator(hash) {
HashNavigation.navigateTo(HashNavigation.setUrlInfoPanel(window.location.hash, hash));
Navigation.navigateTo(Navigation.setUrlInfoPanel(Navigation.currentUrl(), hash));
}
// Request to start a topic, new or selected from search results, or "by ID".
@@ -1911,7 +1911,7 @@ class TinodeWeb extends React.Component {
nextState.topicSelected = newName;
}
this.setState(nextState, _ => {
HashNavigation.navigateTo(HashNavigation.setUrlTopic('', newName));
Navigation.navigateTo(Navigation.setUrlTopic('', newName));
});
}
@@ -2033,7 +2033,7 @@ class TinodeWeb extends React.Component {
this.tinode.onAutoreconnectIteration = this.handleAutoreconnectIteration;
this.tinode.onInfoMessage = this.handleInfoMessage;
this.tinode.onDataMessage = this.handleDataMessage;
HashNavigation.navigateTo('');
Navigation.navigateTo('');
})
});
}
@@ -2053,7 +2053,7 @@ class TinodeWeb extends React.Component {
// Request to hard-delete topic.
topic.delTopic(true).then(_ => {
// Hide MessagesView and InfoView panels.
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), ''));
}).catch(err => {
this.handleError(err.message, 'err');
});
@@ -2078,7 +2078,7 @@ class TinodeWeb extends React.Component {
topic.leave(true).then(_ => {
// Hide MessagesView and InfoView panels.
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), ''));
}).catch(err => {
this.handleError(err.message, 'err');
});
@@ -2093,7 +2093,7 @@ class TinodeWeb extends React.Component {
topic.updateMode(null, '-JP')
.then(_ => {
// Hide MessagesView and InfoView panels.
HashNavigation.navigateTo(HashNavigation.setUrlTopic(window.location.hash, ''));
Navigation.navigateTo(Navigation.setUrlTopic(Navigation.currentUrl(), ''));
})
.catch(err => this.handleError(err.message, 'err'));
}
@@ -2118,10 +2118,10 @@ class TinodeWeb extends React.Component {
// Remove J and P permissions (the "block" half of "block and report").
topic.updateMode(null, '-JP')
.then(_ => {
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
hash = HashNavigation.setUrlSidePanel(hash, 'abuse-report-reasons');
Object.entries(params).forEach(([key, value]) => { hash = HashNavigation.addUrlParam(hash, key, value); });
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlTopic(Navigation.currentUrl(), '');
hash = Navigation.setUrlSidePanel(hash, 'abuse-report-reasons');
Object.entries(params).forEach(([key, value]) => { hash = Navigation.addUrlParam(hash, key, value); });
Navigation.navigateTo(hash);
})
.catch(err => this.handleError(err.message, 'err'));
}
@@ -2352,10 +2352,10 @@ class TinodeWeb extends React.Component {
...data.user,
capabilities: {can_add_friend: !!data.user.can_add}
});
let hash = HashNavigation.setUrlTopic(window.location.hash, '');
hash = HashNavigation.setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
hash = HashNavigation.addUrlParam(hash, 'uid', data.user.user_id);
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlTopic(Navigation.currentUrl(), '');
hash = Navigation.setUrlSidePanel(hash, data.user.relationship === 'friend' ? 'friend-profile' : 'group-member-profile');
hash = Navigation.addUrlParam(hash, 'uid', data.user.user_id);
Navigation.navigateTo(hash);
return;
}
this.setState({scannerOpen: false});
@@ -2400,7 +2400,7 @@ class TinodeWeb extends React.Component {
// The "信息" context-menu item used to always open the old, pre-migration InfoView (info-view.jsx)
// -- for P2P/group topics that's a real, previously-unnoticed gap: the new contact-management-view.jsx
// "friend-settings" / group-management-view.jsx "group-settings" pages (built and verified across
// Ch.5/6, with real HashNavigation.goBack()-based back navigation) had no way to actually be reached
// Ch.5/6, with real Navigation.goBack()-based back navigation) had no way to actually be reached
// from an open chat's own header -- every test of them this engagement typed the hash directly.
// InfoView's own back button (handleBackNavigate in info-view.jsx) still hardcodes a single fixed
// parent per panel and never used real browser history, so routing here fixed both problems at once
@@ -2408,25 +2408,25 @@ class TinodeWeb extends React.Component {
handleShowInfoView() {
const topicName = this.state.topicSelected;
if (Tinode.isP2PTopicName(topicName)) {
// Base the new hash on '' (not window.location.hash) -- the current hash's path[1] still holds
// Base the new URL on '' (not Navigation.currentUrl()) -- the current URL's path[1] still holds
// the open chat's own topic, and setUrlSidePanel only ever touches path[0]. Reusing the current
// hash as-is here leaves that topic sitting in path[1] where it collides with group-settings'/
// friend-settings' own query params, and handleHashRoute re-interprets the leftover path[1] as a
// URL as-is here leaves that topic sitting in path[1] where it collides with group-settings'/
// friend-settings' own query params, and handleRouteChange re-interprets the leftover path[1] as a
// fresh topic selection, blanking the whole view -- the exact bug already found and fixed for
// handleShowAnnouncement/handleShowJoinRequests/handleSendFriendVerification; same fix here.
let hash = HashNavigation.setUrlSidePanel('', 'friend-settings');
hash = HashNavigation.addUrlParam(hash, 'uid', topicName);
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel('', 'friend-settings');
hash = Navigation.addUrlParam(hash, 'uid', topicName);
Navigation.navigateTo(hash);
return;
}
if (!Tinode.isSelfTopicName(topicName)) {
let hash = HashNavigation.setUrlSidePanel('', 'group-settings');
hash = HashNavigation.addUrlParam(hash, 'topic', topicName);
HashNavigation.navigateTo(hash);
let hash = Navigation.setUrlSidePanel('', 'group-settings');
hash = Navigation.addUrlParam(hash, 'topic', topicName);
Navigation.navigateTo(hash);
return;
}
// "已保存的消息" (self topic) has no equivalent new settings page -- keep the old InfoView for it.
HashNavigation.navigateTo(HashNavigation.addUrlParam(window.location.hash, 'info', 'info'));
Navigation.navigateTo(Navigation.addUrlParam(Navigation.currentUrl(), 'info', 'info'));
this.setState({infoPanel: 'info'});
}
@@ -2459,7 +2459,7 @@ class TinodeWeb extends React.Component {
if (this.tinode.isAuthenticated()) {
// Adding new email or phone number in account setting.
this.tinode.getMeTopic().setMeta({cred: {meth: cred, resp: code}})
.then(_ => HashNavigation.navigateTo(HashNavigation.setUrlSidePanel(window.location.hash, 'contacts')))
.then(_ => Navigation.navigateTo(Navigation.setUrlSidePanel(Navigation.currentUrl(), 'contacts')))
.catch(err => this.handleError(err.message, 'err'));
} else {
// Credential validation on signup.
@@ -2487,7 +2487,7 @@ class TinodeWeb extends React.Component {
.then(_ => this.tinode.updateAccountBasic(null, null, newPassword, {scheme: tempAuth.scheme, secret: secret}))
.then(_ => {
this.handleError(this.props.intl.formatMessage(messages.password_reset_success), 'info');
HashNavigation.navigateTo('');
Navigation.navigateTo('');
})
.catch(err => {
this.handleError(err.message, 'err');
+2 -2
View File
@@ -1,7 +1,7 @@
// Edit account parameters.
import React from 'react';
import { FormattedMessage } from 'react-intl';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import { WALLPAPER_DEFAULTS } from '../config.js';
import { wallpaperNameFromUrl, wallpaperTypeFromUrl } from '../lib/utils.js';
import imageIndex from '../../img/bkg/index.json';
@@ -31,7 +31,7 @@ export default class WallpapersView extends React.PureComponent {
e.preventDefault();
// Same-page tab switch (pattern / image), not a navigation to a different page -- see the same
// fix/comment in new-topic-view.jsx's handleTabClick.
HashNavigation.navigateReplace(HashNavigation.addUrlParam(window.location.hash, 'tab', e.currentTarget.dataset.id));
Navigation.navigateReplace(Navigation.addUrlParam(Navigation.currentUrl(), 'tab', e.currentTarget.dataset.id));
this.setState({tab: e.currentTarget.dataset.id});
}
+7 -7
View File
@@ -2,7 +2,7 @@ import React from 'react';
import ContactList from '../widgets/contact-list.jsx';
import HashNavigation from '../lib/navigation.js';
import Navigation from '../lib/navigation.js';
import {
uploadLargeFile, getMe, getFileUrl, getUserProfile,
getWorkbench,
@@ -15,20 +15,20 @@ import {
import { useCachedData, readCachedData, writeCachedData } from '../lib/use-cached-data.js';
const navigate = (view, params) => {
let hash = HashNavigation.setUrlSidePanel(window.location.hash, view);
let hash = Navigation.setUrlSidePanel(Navigation.currentUrl(), view);
Object.entries(params || {}).forEach(([key, value]) => {
hash = value ? HashNavigation.addUrlParam(hash, key, value) : HashNavigation.removeUrlParam(hash, key);
hash = value ? Navigation.addUrlParam(hash, key, value) : Navigation.removeUrlParam(hash, key);
});
HashNavigation.navigateTo(hash);
Navigation.navigateTo(hash);
};
// Same bug/fix as content-library-view.jsx's navigateReplace: DailyReportList/MonthlyReportList's
// 我的日报|月报 <-> 团队统计 switch is a same-page tab toggle (same component, just a `team` boolean),
// not a navigation to a different page -- using navigate() here pushed a history entry per click, so
// switching back and forth a few times made the system back button (and this app's own Header back
// button, gated on HashNavigation.goBack()) cycle through past toggle states instead of leaving the
// button, gated on Navigation.goBack()) cycle through past toggle states instead of leaving the
// report page. replaceState-based navigateReplace keeps the hash in sync without that side effect.
const navigateReplace = (view) => HashNavigation.navigateReplace(HashNavigation.setUrlSidePanel(window.location.hash, view));
const navigateReplace = (view) => Navigation.navigateReplace(Navigation.setUrlSidePanel(Navigation.currentUrl(), view));
export const WORKPLACE_COLLABORATION_VIEWS = [
'workbench', 'organization-directory', 'meeting-organizer', 'meeting-attendee', 'meeting-create', 'meeting-detail',
@@ -41,7 +41,7 @@ function Icon({children}) { return <i className="material-icons" aria-hidden="tr
function Header({title, back = 'workbench', action, actionLabel, onAction, actionDisabled}) {
return <header className="workplace-header">
<button type="button" onClick={() => { if (!HashNavigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<button type="button" onClick={() => { if (!Navigation.goBack()) navigate(back); }} aria-label="返回"><Icon>arrow_back_ios_new</Icon></button>
<h1>{title}</h1>
{action ? <button type="button" onClick={onAction} aria-label={actionLabel || '操作'} disabled={actionDisabled}><Icon>{action}</Icon></button> : <span />}
</header>;