Skip to content

Commit e98c8e9

Browse files
committed
support offline
1 parent ac381f1 commit e98c8e9

19 files changed

Lines changed: 820 additions & 265 deletions

File tree

app/src/App.tsx

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,28 @@
11
import { MainView } from './views/Main'
2+
import { DomainOfflineBanner } from './components/DomainOfflineBanner'
23

34
function App() {
45
return (
56
<div
67
style={{
78
width: '100%',
89
height: '100%',
9-
position: 'relative',
10+
display: 'flex',
11+
flexDirection: 'column',
1012
overflow: 'hidden'
1113
}}
1214
>
13-
<MainView />
15+
<DomainOfflineBanner />
16+
<div
17+
style={{
18+
flex: 1,
19+
minHeight: 0,
20+
position: 'relative',
21+
overflow: 'hidden'
22+
}}
23+
>
24+
<MainView />
25+
</div>
1426
</div>
1527
)
1628
}

app/src/components/Composer.tsx

Lines changed: 19 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -52,7 +52,7 @@ interface Props {
5252
}
5353

5454
export const Composer = (props: Props) => {
55-
const { client } = useClient()
55+
const { client, isDomainOffline } = useClient()
5656
const [draft, setDraft] = useState<string>(props.draftBuffer?.draftText ?? '')
5757
const [postHome, setPostHome] = useState<boolean>(props.draftBuffer?.postHome ?? true)
5858
const [selectedProfile, setSelectedProfile] = useState<string>(
@@ -242,6 +242,7 @@ export const Composer = (props: Props) => {
242242

243243
const cannotSubmit =
244244
uploading ||
245+
isDomainOffline ||
245246
(displayMode !== 'media' && displayMode !== 'reroute' && draft.trim() === '') ||
246247
(displayMode === 'media' && mediaDrafts.length === 0)
247248

@@ -696,16 +697,23 @@ export const Composer = (props: Props) => {
696697
</IconButton>
697698
)}
698699
</div>
699-
<Button
700-
onClick={handleSubmit}
701-
disabled={cannotSubmit}
702-
endIcon={<MdSend />}
703-
style={{
704-
minWidth: '100px'
705-
}}
706-
>
707-
{getSubmitLabel()}
708-
</Button>
700+
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
701+
{isDomainOffline && (
702+
<Text variant="caption" style={{ margin: 0 }}>
703+
オフラインのため投稿できません
704+
</Text>
705+
)}
706+
<Button
707+
onClick={handleSubmit}
708+
disabled={cannotSubmit}
709+
endIcon={<MdSend />}
710+
style={{
711+
minWidth: '100px'
712+
}}
713+
>
714+
{getSubmitLabel()}
715+
</Button>
716+
</div>
709717
</div>
710718

711719
{/* 隠しファイル入力 */}
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import { Button, Text } from '@concrnt/ui'
2+
import { useClient } from '../contexts/Client'
3+
4+
// ホームサーバーがオフラインのとき(読み取り専用モード)に表示する帯
5+
export const DomainOfflineBanner = () => {
6+
const { client, isDomainOffline, domainRecovered, reload } = useClient()
7+
8+
if (!isDomainOffline) return null
9+
10+
return (
11+
<div
12+
style={{
13+
width: '100%',
14+
boxSizing: 'border-box',
15+
backgroundColor: domainRecovered ? '#2e7d32' : '#d32f2f',
16+
color: '#ffffff',
17+
paddingTop: 'calc(env(safe-area-inset-top) + 6px)',
18+
paddingBottom: '6px',
19+
paddingLeft: '8px',
20+
paddingRight: '8px',
21+
textAlign: 'center',
22+
flexShrink: 0,
23+
display: 'flex',
24+
justifyContent: 'center',
25+
alignItems: 'center',
26+
gap: '8px'
27+
}}
28+
>
29+
{domainRecovered ? (
30+
<>
31+
<Text variant="caption" style={{ color: '#ffffff', margin: 0 }}>
32+
サーバーが復旧しました
33+
</Text>
34+
<Button
35+
variant="text"
36+
style={{ color: '#ffffff', minHeight: 0, padding: '2px 8px' }}
37+
onClick={() => {
38+
reload()
39+
}}
40+
>
41+
再接続
42+
</Button>
43+
</>
44+
) : (
45+
<Text variant="caption" style={{ color: '#ffffff', margin: 0 }}>
46+
あなたのドメイン{client.api.defaultHost}
47+
は現在オフラインのため、読み取り専用モードです。復旧までしばらくお待ちください。
48+
</Text>
49+
)}
50+
</div>
51+
)
52+
}

app/src/components/RealtimeTimeline.tsx

Lines changed: 72 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ interface Props extends ScrollViewProps {
2525
const SCROLL_HALT_THRESHOLD = 100
2626

2727
export const RealtimeTimeline = (props: Props) => {
28-
const { client } = useClient()
28+
const { client, isDomainOffline } = useClient()
2929

3030
const loadingRef = useRef(true)
3131
const [loading, setLoading] = useState(true)
@@ -48,6 +48,33 @@ export const RealtimeTimeline = (props: Props) => {
4848
newArrivalsRef.current = newArrivals
4949
}, [newArrivals])
5050

51+
// 自ドメインがオフラインでも、単一タイムラインならそのホストから直接読める。
52+
// reader再生成のトリガーは解決済みのhostOverride値の変化のみにする
53+
// (isDomainOffline自体を依存にすると、一時的なオフライン遷移のたびに表示中のリーダーが破棄されてしまう)
54+
const [hostOverride, setHostOverride] = useState<string | undefined>(undefined)
55+
useEffect(() => {
56+
if (!client || !isDomainOffline || props.timelines.length !== 1) {
57+
setHostOverride(undefined)
58+
return
59+
}
60+
let isCancelled = false
61+
const owner = URL.parse(props.timelines[0])?.host
62+
if (!owner) {
63+
setHostOverride(undefined)
64+
return
65+
}
66+
client.api
67+
.resolveDomain(owner)
68+
.catch(() => undefined)
69+
.then((fqdn) => {
70+
if (isCancelled) return
71+
setHostOverride(fqdn === client.api.defaultHost ? undefined : fqdn)
72+
})
73+
return () => {
74+
isCancelled = true
75+
}
76+
}, [client, isDomainOffline, props.timelines])
77+
5178
useEffect(() => {
5279
console.log('Initializing timeline reader for timelines:', props.timelines)
5380
let isCancelled = false
@@ -56,53 +83,62 @@ export const RealtimeTimeline = (props: Props) => {
5683
const request = async () => {
5784
if (!client) return
5885

59-
return client.newTimelineReader().then((t) => {
60-
if (isCancelled) return
61-
t.haltUpdate = false
62-
t.onUpdate = () => {
63-
startTransition(() => {
64-
update()
65-
})
66-
}
67-
68-
t.onNewItem = (item) => {
86+
return client
87+
.newTimelineReader({ withoutSocket: false, hostOverride })
88+
.catch(() => client.newTimelineReader({ withoutSocket: true, hostOverride }))
89+
.then((t) => {
6990
if (isCancelled) return
70-
if (!t.haltUpdate) return
71-
if (!item.href) return
91+
t.haltUpdate = false
92+
t.onUpdate = () => {
93+
startTransition(() => {
94+
update()
95+
})
96+
}
7297

73-
client.getMessage(item.href).then((msg) => {
98+
t.onNewItem = (item) => {
7499
if (isCancelled) return
75-
if (!msg) return
76-
const icon = msg.authorProfile?.avatar
77-
if (!icon) return
78-
setNewArrivals((prev) => {
79-
if (prev.find((e) => e.src === icon)) return prev
80-
return [{ id: item.href!, author: msg.author, src: icon }, ...prev]
100+
if (!t.haltUpdate) return
101+
if (!item.href) return
102+
103+
client.getMessage(item.href).then((msg) => {
104+
if (isCancelled) return
105+
if (!msg) return
106+
const icon = msg.authorProfile?.avatar
107+
if (!icon) return
108+
setNewArrivals((prev) => {
109+
if (prev.find((e) => e.src === icon)) return prev
110+
return [{ id: item.href!, author: msg.author, src: icon }, ...prev]
111+
})
81112
})
82-
})
83-
}
113+
}
84114

85-
reader.current = t
86-
t.listen(props.timelines)
87-
.then((hasMoreData) => {
88-
setHasMoreData(hasMoreData)
89-
})
90-
.finally(() => {
91-
loadingRef.current = false
92-
setLoading(false)
93-
setInitialLoaded(true)
94-
})
95-
return t
96-
})
115+
reader.current = t
116+
t.listen(props.timelines)
117+
.then((hasMoreData) => {
118+
setHasMoreData(hasMoreData)
119+
})
120+
.finally(() => {
121+
loadingRef.current = false
122+
setLoading(false)
123+
setInitialLoaded(true)
124+
})
125+
return t
126+
})
97127
}
98-
const mt = request()
128+
const mt = request().catch((err) => {
129+
console.error('Failed to initialize timeline reader:', err)
130+
loadingRef.current = false
131+
setLoading(false)
132+
setInitialLoaded(true)
133+
return undefined
134+
})
99135
return () => {
100136
isCancelled = true
101137
mt.then((t) => {
102138
t?.dispose()
103139
})
104140
}
105-
}, [client, reader, props.timelines, update])
141+
}, [client, reader, props.timelines, update, hostOverride])
106142

107143
const scrollRef = useRef<HTMLDivElement>(null)
108144

0 commit comments

Comments
 (0)