diff --git a/web/src/features/settings/settings-page.tsx b/web/src/features/settings/settings-page.tsx
index 14b0095..23b17c9 100644
--- a/web/src/features/settings/settings-page.tsx
+++ b/web/src/features/settings/settings-page.tsx
@@ -184,7 +184,7 @@ export function SettingsPage() {
?
: update('name', event.target.value)} disabled={!member} required maxLength={80} autoComplete="nickname" />}
-
+
update('email', event.target.value)} disabled={!member} maxLength={320} autoComplete="email" placeholder="name@example.com" />
diff --git a/web/src/shared/ui/avatar.tsx b/web/src/shared/ui/avatar.tsx
index ca06507..394aab7 100644
--- a/web/src/shared/ui/avatar.tsx
+++ b/web/src/shared/ui/avatar.tsx
@@ -1,11 +1,50 @@
-import { useState } from 'react'
+import { useEffect, useState } from 'react'
+import { Store } from '@/shared/lib/store'
+import { useStore } from '@/shared/lib/use-store'
+
+const gravatar = 'https://www.gravatar.com/avatar/'
+const mirrors = ['https://weavatar.com/avatar/', 'https://cravatar.cn/avatar/']
+const gravatarTimeoutMs = 3000
+const mirrorTimeoutMs = 5000
+
+// 有的网络访问不到 Gravatar,要改用镜像。头像加载失败分不清是没有头像(d=404)还是网络不通,所以另外探测:
+// no-cors 请求拿到任何响应都算能访问。探测的地址要返回图片,其他类型的不透明响应可能被浏览器当作网络错误。
+// 丢包的网络要等连接超时才会失败,所以限时。
+function reachable(base: string, timeoutMs: number): Promise {
+ return fetch(`${base}?s=1&d=blank`, { mode: 'no-cors', signal: AbortSignal.timeout(timeoutMs) }).then(() => base)
+}
+
+// 镜像响应慢,逐个探测既慢又容易超时,所以同时探测、用先响应的那个;都不通时只显示首字。
+class AvatarBase extends Store<{ url: string | null }> {
+ #probing = false
+
+ constructor() {
+ super({ url: null })
+ }
+
+ probe(): void {
+ if (this.#probing)
+ return
+ this.#probing = true
+ void reachable(gravatar, gravatarTimeoutMs)
+ .catch(() => Promise.any(mirrors.map(base => reachable(base, mirrorTimeoutMs))))
+ .then(url => this.setState({ url }), () => {})
+ }
+}
+
+const avatarBase = new AvatarBase()
export function Avatar({ hash, name, className = '' }: { hash: string, name: string, className?: string }) {
+ const base = useStore(avatarBase, s => s.url)
const [failedHash, setFailedHash] = useState()
+ useEffect(() => {
+ if (hash)
+ avatarBase.probe()
+ }, [hash])
return (
- {hash && hash !== failedHash
- ?
setFailedHash(hash)} />
+ {base && hash && hash !== failedHash
+ ?
setFailedHash(hash)} />
: Array.from(name.trim())[0] ?? '?'}
)