feat(web)!: sign in before joining a space and drop the stranger option
Opening a space link while signed out shows the sign-in page first and keeps the link in memory. The channel settings no longer offer the stranger switch, and the client supports API contract major 8.
This commit is contained in:
1 parent
c858bd2a09
commit
8233369cd4
16 files changed
+27
-30
No files matched your search
@@ -134,7 +134,7 @@ const { complete, nextIndex, meta } = await result;
|
|||||||
|
|
||||||
## 必须注意的几点
|
## 必须注意的几点
|
||||||
|
|
||||||
**1. 访客终端密钥必须持久化。** `session.endpointSecret`(32 字节)标识「这个访客终端在这个频道里」,服务端按它登记访客以便认证和移除;登录账号以账号会话鉴权,不需要它,同一账号的多台设备也不重复占名额。访客不持久化的话,每次打开链接都会登记一个新访客终端。用 IndexedDB 保存,下次 `open(link, { endpointSecret })` 传回来。
|
**1. 终端密钥只用于便捷分享。** `session.endpointSecret`(32 字节)标识「这个终端」,便捷分享的创建者与读者靠它登记和续传;频道与空间只凭账号会话鉴权,不使用它。分享读者不持久化的话,每次打开链接都会登记一个新访客终端,用 IndexedDB 保存。
|
||||||
|
|
||||||
**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。
|
**2. Worker。** `ChannelSession` 实例不能跨线程传递(它持有 `CryptoKey` 和闭包)。在 Worker 里用链接和 `endpointSecret` 重建它,主线程只传链接、帧字节和事件。
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ export type AuthRole = 'read' | 'write' | 'owner';
|
|||||||
/** 频道设置,字段名与 API 一致(snake_case)。 */
|
/** 频道设置,字段名与 API 一致(snake_case)。 */
|
||||||
export interface ChannelSettings {
|
export interface ChannelSettings {
|
||||||
join_policy?: 'open' | 'invite';
|
join_policy?: 'open' | 'invite';
|
||||||
allow_guests?: boolean;
|
|
||||||
name?: string;
|
name?: string;
|
||||||
max_members?: number;
|
max_members?: number;
|
||||||
members_can_upload?: boolean;
|
members_can_upload?: boolean;
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ let dataDir = '';
|
|||||||
let base = '';
|
let base = '';
|
||||||
let accountToken = '';
|
let accountToken = '';
|
||||||
function headersFor(session: ChannelSession, role: AuthRole): Record<string, string> {
|
function headersFor(session: ChannelSession, role: AuthRole): Record<string, string> {
|
||||||
return { ...session.authHeaders(role), ...(session.role === 'owner' ? { 'X-WD-Account': accountToken } : {}) };
|
// 频道只接受登录账号:测试里所有角色共用同一个账号,权限差异由角色令牌体现
|
||||||
|
return { ...session.authHeaders(role), 'X-WD-Account': accountToken };
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeAll(async () => {
|
beforeAll(async () => {
|
||||||
@@ -145,7 +146,7 @@ function subscribe(session: ChannelSession, role: AuthRole, until: (events: Item
|
|||||||
const h = headersFor(session, role);
|
const h = headersFor(session, role);
|
||||||
const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`];
|
const protocols = ['wd.v1', `wd.role.${role}`, `wd.ep.${h['X-WD-Endpoint']!}`];
|
||||||
if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`);
|
if (h.Authorization) protocols.push(`wd.auth.${h.Authorization.replace('Bearer ', '')}`);
|
||||||
if (session.role === 'owner') protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`);
|
protocols.push(`wd.account.${accountToken.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')}`);
|
||||||
|
|
||||||
const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols);
|
const ws = new WebSocket(`${base.replace('http', 'ws')}/api/v1/channels/${session.id}/ws?from=${from}`, protocols);
|
||||||
ws.binaryType = 'arraybuffer';
|
ws.binaryType = 'arraybuffer';
|
||||||
@@ -416,7 +417,7 @@ suite('端到端(真实服务端)', () => {
|
|||||||
const member = await ChannelSession.open(owner.link(ORIGIN));
|
const member = await ChannelSession.open(owner.link(ORIGIN));
|
||||||
await joinChannel(member, 'read');
|
await joinChannel(member, 'read');
|
||||||
|
|
||||||
const res = await fetch(api(`/channels/${owner.id}/guests`), { headers: { ...headersFor(member, 'read') } });
|
const res = await fetch(api(`/channels/${owner.id}/settings`), { method: 'PATCH', headers: { ...headersFor(member, 'read'), 'Content-Type': 'application/json' }, body: '{}' });
|
||||||
expect(res.status).toBe(403);
|
expect(res.status).toBe(403);
|
||||||
expect(() => headersFor(member, 'owner')).toThrow();
|
expect(() => headersFor(member, 'owner')).toThrow();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -68,6 +68,8 @@ export function App() {
|
|||||||
else if (route.name === 'share' && waitingOnSavedShare) screen = null;
|
else if (route.name === 'share' && waitingOnSavedShare) screen = null;
|
||||||
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
|
else if (route.name === 'share') screen = <ShareScreen id={route.id} />;
|
||||||
else if (route.name === 'channel' && waitingOnSavedChannel) screen = null;
|
else if (route.name === 'channel' && waitingOnSavedChannel) screen = null;
|
||||||
|
// 空间只接受登录账号:未登录先登录,链接留在内存里,登录后继续加入
|
||||||
|
else if (route.name === 'channel' && !username) screen = <AccountScreen />;
|
||||||
else if (saved) screen = <ChannelScreen id={route.id} />;
|
else if (saved) screen = <ChannelScreen id={route.id} />;
|
||||||
else if (pendingLink?.id === route.id) {
|
else if (pendingLink?.id === route.id) {
|
||||||
screen = (
|
screen = (
|
||||||
|
|||||||
@@ -3,9 +3,9 @@ import { supportsApiContract } from './contract';
|
|||||||
|
|
||||||
describe('API contract compatibility', () => {
|
describe('API contract compatibility', () => {
|
||||||
it('accepts compatible SemVer releases in the supported major', () => {
|
it('accepts compatible SemVer releases in the supported major', () => {
|
||||||
expect(supportsApiContract('7.0.0')).toBe(true);
|
expect(supportsApiContract('8.0.0')).toBe(true);
|
||||||
expect(supportsApiContract('7.8.2')).toBe(true);
|
expect(supportsApiContract('8.8.2')).toBe(true);
|
||||||
expect(supportsApiContract('7.2.0-rc.1')).toBe(true);
|
expect(supportsApiContract('8.2.0-rc.1')).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects a breaking major and malformed versions', () => {
|
it('rejects a breaking major and malformed versions', () => {
|
||||||
@@ -13,6 +13,7 @@ describe('API contract compatibility', () => {
|
|||||||
expect(supportsApiContract('4.0.0')).toBe(false);
|
expect(supportsApiContract('4.0.0')).toBe(false);
|
||||||
expect(supportsApiContract('5.0.0')).toBe(false);
|
expect(supportsApiContract('5.0.0')).toBe(false);
|
||||||
expect(supportsApiContract('6.1.0')).toBe(false);
|
expect(supportsApiContract('6.1.0')).toBe(false);
|
||||||
|
expect(supportsApiContract('7.0.0')).toBe(false);
|
||||||
expect(supportsApiContract('01.0.0')).toBe(false);
|
expect(supportsApiContract('01.0.0')).toBe(false);
|
||||||
expect(supportsApiContract(undefined)).toBe(false);
|
expect(supportsApiContract(undefined)).toBe(false);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
export const SUPPORTED_API_CONTRACT_MAJOR = 7;
|
export const SUPPORTED_API_CONTRACT_MAJOR = 8;
|
||||||
|
|
||||||
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-([0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*))?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
||||||
|
|
||||||
|
|||||||
@@ -4,5 +4,5 @@ import type { CreateOptions } from './types';
|
|||||||
export function creationOptions(defaults: DefaultsBody): CreateOptions {
|
export function creationOptions(defaults: DefaultsBody): CreateOptions {
|
||||||
const settings = defaults.channel;
|
const settings = defaults.channel;
|
||||||
return { mode: defaults.channel_mode, maxMembers: settings.max_members, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
|
return { mode: defaults.channel_mode, maxMembers: settings.max_members, ttlSeconds: settings.ttl_seconds, membersCanUpload: settings.members_can_upload,
|
||||||
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy, allowGuests: settings.allow_guests };
|
fileMaxDownloads: settings.file_max_downloads, joinPolicy: settings.join_policy };
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,6 @@ export interface ChannelInfo {
|
|||||||
dataSource?: 'server' | 'cache';
|
dataSource?: 'server' | 'cache';
|
||||||
creatorAccount?: string;
|
creatorAccount?: string;
|
||||||
joinPolicy?: 'open' | 'invite';
|
joinPolicy?: 'open' | 'invite';
|
||||||
allowGuests?: boolean;
|
|
||||||
passwordRequired?: boolean;
|
passwordRequired?: boolean;
|
||||||
id: string;
|
id: string;
|
||||||
/** 服务端 channel settings 中的共享名称,加入者可见。 */
|
/** 服务端 channel settings 中的共享名称,加入者可见。 */
|
||||||
@@ -33,7 +32,6 @@ export interface ChannelInfo {
|
|||||||
/** 新建空间的选项(protocol §12 设置的子集) */
|
/** 新建空间的选项(protocol §12 设置的子集) */
|
||||||
export interface CreateOptions {
|
export interface CreateOptions {
|
||||||
joinPolicy?: 'open' | 'invite';
|
joinPolicy?: 'open' | 'invite';
|
||||||
allowGuests?: boolean;
|
|
||||||
joinPassword?: string;
|
joinPassword?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
mode: ChannelMode;
|
mode: ChannelMode;
|
||||||
@@ -51,7 +49,6 @@ export interface FileUploadOptions {
|
|||||||
|
|
||||||
export interface ChannelSettingsPatch {
|
export interface ChannelSettingsPatch {
|
||||||
joinPolicy?: 'open' | 'invite';
|
joinPolicy?: 'open' | 'invite';
|
||||||
allowGuests?: boolean;
|
|
||||||
joinPassword?: string;
|
joinPassword?: string;
|
||||||
name?: string;
|
name?: string;
|
||||||
maxMembers?: number;
|
maxMembers?: number;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ export async function createChannel(options: CreateOptions, captchaToken?: strin
|
|||||||
ttl_seconds: options.ttlSeconds,
|
ttl_seconds: options.ttlSeconds,
|
||||||
members_can_upload: options.membersCanUpload,
|
members_can_upload: options.membersCanUpload,
|
||||||
file_max_downloads: options.fileMaxDownloads,
|
file_max_downloads: options.fileMaxDownloads,
|
||||||
join_policy: options.joinPolicy ?? defaults.join_policy, allow_guests: options.allowGuests ?? defaults.allow_guests,
|
join_policy: options.joinPolicy ?? defaults.join_policy,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
try {
|
try {
|
||||||
@@ -157,7 +157,6 @@ export async function updateChannelSettings(resourceId: string, patch: ChannelSe
|
|||||||
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
if (patch.ttlSeconds !== undefined) body.ttl_seconds = patch.ttlSeconds;
|
||||||
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
|
if (patch.fileMaxDownloads !== undefined) body.file_max_downloads = patch.fileMaxDownloads;
|
||||||
if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy;
|
if (patch.joinPolicy !== undefined) body.join_policy = patch.joinPolicy;
|
||||||
if (patch.allowGuests !== undefined) body.allow_guests = patch.allowGuests;
|
|
||||||
if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword;
|
if (patch.joinPassword !== undefined) body.join_password = patch.joinPassword;
|
||||||
const info = await apiFetch(`/channels/${resourceId}/settings`, {
|
const info = await apiFetch(`/channels/${resourceId}/settings`, {
|
||||||
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
|
method: 'PATCH', headers: { ...session.authHeaders('owner'), 'Content-Type': 'application/json' },
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ describe('API contract preflight', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('blocks API requests when the server has a newer breaking major', async () => {
|
it('blocks API requests when the server has a newer breaking major', async () => {
|
||||||
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 }));
|
const fetchMock = vi.fn(async () => new Response(JSON.stringify({ contract_version: '9.0.0' }), { status: 200 }));
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
setApiBase('http://api.test/api/v1');
|
setApiBase('http://api.test/api/v1');
|
||||||
|
|
||||||
@@ -19,7 +19,7 @@ describe('API contract preflight', () => {
|
|||||||
|
|
||||||
it('checks the contract once and then sends the API request', async () => {
|
it('checks the contract once and then sends the API request', async () => {
|
||||||
const fetchMock = vi.fn()
|
const fetchMock = vi.fn()
|
||||||
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '7.0.0' }), { status: 200 }))
|
.mockResolvedValueOnce(new Response(JSON.stringify({ contract_version: '8.0.0' }), { status: 200 }))
|
||||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
|
.mockResolvedValueOnce(new Response('{}', { status: 200 }))
|
||||||
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
.mockResolvedValueOnce(new Response('{}', { status: 200 }));
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
vi.stubGlobal('fetch', fetchMock);
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ export function toChannelInfo(raw: ChannelWire, saved: SavedResource): ChannelIn
|
|||||||
return {
|
return {
|
||||||
id: raw.id,
|
id: raw.id,
|
||||||
creatorAccount: raw.creator_account ?? undefined,
|
creatorAccount: raw.creator_account ?? undefined,
|
||||||
joinPolicy, allowGuests: raw.settings.allow_guests, passwordRequired: raw.password_required,
|
joinPolicy, passwordRequired: raw.password_required,
|
||||||
name: raw.settings.name ?? undefined,
|
name: raw.settings.name ?? undefined,
|
||||||
mode: raw.mode,
|
mode: raw.mode,
|
||||||
role: raw.can_manage_structure ? 'owner' : 'member',
|
role: raw.can_manage_structure ? 'owner' : 'member',
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ interface Props {
|
|||||||
|
|
||||||
export function ChannelSettingsSheet(props: Props) {
|
export function ChannelSettingsSheet(props: Props) {
|
||||||
const { info, error, load } = useServerSettings();
|
const { info, error, load } = useServerSettings();
|
||||||
if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined || props.channel.allowGuests === undefined)
|
if (!info || props.channel.ttlSeconds === undefined || props.channel.membersCanUpload === undefined || props.channel.maxDownloads === undefined || props.channel.joinPolicy === undefined)
|
||||||
return <Sheet title="空间设置" onClose={props.onClose}><p role={error ? 'alert' : 'status'}>{error ?? '正在读取空间设置…'}</p>
|
return <Sheet title="空间设置" onClose={props.onClose}><p role={error ? 'alert' : 'status'}>{error ?? '正在读取空间设置…'}</p>
|
||||||
{error ? <Button onClick={() => { void load().catch(() => {}); }}>重试</Button> : null}</Sheet>;
|
{error ? <Button onClick={() => { void load().catch(() => {}); }}>重试</Button> : null}</Sheet>;
|
||||||
return <SettingsForm {...props} info={info} />;
|
return <SettingsForm {...props} info={info} />;
|
||||||
@@ -29,7 +29,6 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
|||||||
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
|
const [ttlSeconds, setTtlSeconds] = useState(channel.ttlSeconds!);
|
||||||
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
|
const [fileMaxDownloads, setFileMaxDownloads] = useState(channel.maxDownloads!);
|
||||||
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy!);
|
const [joinPolicy, setJoinPolicy] = useState<'open' | 'invite'>(channel.joinPolicy!);
|
||||||
const [allowGuests, setAllowGuests] = useState(channel.allowGuests!);
|
|
||||||
const [joinPassword, setJoinPassword] = useState('');
|
const [joinPassword, setJoinPassword] = useState('');
|
||||||
const [clearPassword, setClearPassword] = useState(false);
|
const [clearPassword, setClearPassword] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
@@ -42,7 +41,7 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
|||||||
setSaving(true);
|
setSaving(true);
|
||||||
setFailed(false);
|
setFailed(false);
|
||||||
try {
|
try {
|
||||||
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy, allowGuests,
|
await onSave({ name: name.trim(), maxMembers, membersCanUpload, ttlSeconds, fileMaxDownloads, joinPolicy,
|
||||||
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
...(clearPassword ? { joinPassword: '' } : joinPassword ? { joinPassword } : {}) });
|
||||||
onClose();
|
onClose();
|
||||||
} catch {
|
} catch {
|
||||||
@@ -87,10 +86,9 @@ function SettingsForm({ channel, onClose, onSave, info }: Props & { info: Server
|
|||||||
|
|
||||||
<label className={styles.field}><span>加入方式</span>
|
<label className={styles.field}><span>加入方式</span>
|
||||||
<select value={joinPolicy} disabled={!owner} onChange={(event) => setJoinPolicy(event.target.value as 'open' | 'invite')}>
|
<select value={joinPolicy} disabled={!owner} onChange={(event) => setJoinPolicy(event.target.value as 'open' | 'invite')}>
|
||||||
<option value="open">持链接加入</option><option value="invite">仅受邀账号加入</option>
|
<option value="open">登录后持链接加入</option><option value="invite">仅受邀账号加入</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label className={styles.check}><input type="checkbox" checked={allowGuests} disabled={!owner || joinPolicy === 'invite'} onChange={(event) => setAllowGuests(event.target.checked)} /><span>允许未登录的陌生人加入</span></label>
|
|
||||||
<label className={styles.field}><span>{channel.passwordRequired ? '更换空间加入密码' : '空间加入密码(可选)'}</span>
|
<label className={styles.field}><span>{channel.passwordRequired ? '更换空间加入密码' : '空间加入密码(可选)'}</span>
|
||||||
<input type="password" autoComplete="new-password" minLength={8} maxLength={256} value={joinPassword} disabled={!owner || clearPassword} placeholder="至少 8 个字符,留空不修改" onChange={(event) => setJoinPassword(event.target.value)} />
|
<input type="password" autoComplete="new-password" minLength={8} maxLength={256} value={joinPassword} disabled={!owner || clearPassword} placeholder="至少 8 个字符,留空不修改" onChange={(event) => setJoinPassword(event.target.value)} />
|
||||||
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
|
<span className={styles.note}>空间主及受邀用户无需加入密码。修改规则不移除已加入成员。</span>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { createContext, useContext } from 'react';
|
import { createContext, useContext } from 'react';
|
||||||
import type { ArchiveEntry, Item } from '@/core-bridge/types';
|
import type { ArchiveEntry, Item } from '@/core-bridge/types';
|
||||||
import { GUEST_LABEL } from '@/lib/sender';
|
import { UNKNOWN_SENDER_LABEL } from '@/lib/sender';
|
||||||
import { useShareSession } from '@/store/shareSession';
|
import { useShareSession } from '@/store/shareSession';
|
||||||
import { useWorkspace } from '@/store/workspace';
|
import { useWorkspace } from '@/store/workspace';
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ export interface ItemSource {
|
|||||||
|
|
||||||
export const channelSource: ItemSource = {
|
export const channelSource: ItemSource = {
|
||||||
useItem: (id) => useWorkspace((s) => s.byId[id]),
|
useItem: (id) => useWorkspace((s) => s.byId[id]),
|
||||||
fallbackSender: GUEST_LABEL,
|
fallbackSender: UNKNOWN_SENDER_LABEL,
|
||||||
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
|
getBlob: (id, versionId) => useWorkspace.getState().getBlob(id, versionId),
|
||||||
listArchive: (id) => useWorkspace.getState().listArchive(id),
|
listArchive: (id) => useWorkspace.getState().listArchive(id),
|
||||||
actions: {
|
actions: {
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ export function JoinPreview({ link, onJoin, onJoined, onBack, backLabel = '重
|
|||||||
{link.isOwner ? '所有者 · 含管理权限' : link.canWrite ? '成员 · 可上传' : '成员'}
|
{link.isOwner ? '所有者 · 含管理权限' : link.canWrite ? '成员 · 可上传' : '成员'}
|
||||||
</dd>
|
</dd>
|
||||||
</dl>
|
</dl>
|
||||||
<p className={styles.dim}>{preview?.joinPolicy === 'invite' ? '这个空间仅允许受邀账号加入。' : preview?.allowGuests === false ? '这个空间需要登录账号后加入。' : '未登录用户将以陌生人设备身份加入。'}符合加入规则后可读取空间历史。</p>
|
<p className={styles.dim}>{preview?.joinPolicy === 'invite' ? '这个空间仅允许受邀账号加入。' : '登录账号后即可加入。'}符合加入规则后可读取空间历史。</p>
|
||||||
{preview?.passwordRequired ? <label>空间加入密码<input type="password" autoComplete="off" maxLength={256} value={password} onChange={(event) => setPassword(event.target.value)} /></label> : null}
|
{preview?.passwordRequired ? <label>空间加入密码<input type="password" autoComplete="off" maxLength={256} value={password} onChange={(event) => setPassword(event.target.value)} /></label> : null}
|
||||||
{phase === 'failed' ? <p className={styles.error} role="alert">{error}</p> : null}
|
{phase === 'failed' ? <p className={styles.error} role="alert">{error}</p> : null}
|
||||||
<div className={styles.row}>
|
<div className={styles.row}>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import type { SenderInfo } from '@/core-bridge/types';
|
import type { SenderInfo } from '@/core-bridge/types';
|
||||||
|
|
||||||
/** 没有账号归属的内容(未登录访客)的显示名;设备只是账号的属性,不作为发送者展示 */
|
/** 找不到作者信息时的显示名(频道只有登录账号,正常不会用到);设备只是账号的属性,不作为发送者展示 */
|
||||||
export const GUEST_LABEL = '访客';
|
export const UNKNOWN_SENDER_LABEL = '未知成员';
|
||||||
|
|
||||||
export function senderLabel(source: { author?: SenderInfo } | undefined, fallback = GUEST_LABEL): string {
|
export function senderLabel(source: { author?: SenderInfo } | undefined, fallback = UNKNOWN_SENDER_LABEL): string {
|
||||||
return source?.author?.displayName || source?.author?.username || fallback;
|
return source?.author?.displayName || source?.author?.username || fallback;
|
||||||
}
|
}
|
||||||
+1
-1
@@ -25,7 +25,7 @@ createRoot(document.getElementById('root')!).render(
|
|||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Restore the account scope before loading any account/guest resources.
|
// Restore the account scope before loading any account resources.
|
||||||
void useAccount.getState().refresh().then(() => Promise.all([
|
void useAccount.getState().refresh().then(() => Promise.all([
|
||||||
useChannels.getState().hydrate(), useShares.getState().hydrate(),
|
useChannels.getState().hydrate(), useShares.getState().hydrate(),
|
||||||
]));
|
]));
|
||||||
Reference in new issue
Block a user