1
0
Fork 0
forked from Mirrors/gomuks

hicli/sync,web/mainscreen: add sync status indicator

Fixes #500
This commit is contained in:
Tulir Asokan 2024-11-21 00:47:48 +02:00
parent 74e97c5c8c
commit f3717505bf
12 changed files with 139 additions and 22 deletions

2
go.mod
View file

@ -24,7 +24,7 @@ require (
golang.org/x/text v0.20.0
gopkg.in/yaml.v3 v3.0.1
maunium.net/go/mauflag v1.0.0
maunium.net/go/mautrix v0.22.1-0.20241118181122-363fdfa3b227
maunium.net/go/mautrix v0.22.1-0.20241120171618-4cd2bb62ff01
mvdan.cc/xurls/v2 v2.5.0
)

4
go.sum
View file

@ -89,7 +89,7 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
maunium.net/go/mauflag v1.0.0 h1:YiaRc0tEI3toYtJMRIfjP+jklH45uDHtT80nUamyD4M=
maunium.net/go/mauflag v1.0.0/go.mod h1:nLivPOpTpHnpzEh8jEdSL9UqO9+/KBJFmNRlwKfkPeA=
maunium.net/go/mautrix v0.22.1-0.20241118181122-363fdfa3b227 h1:VsOldmYtN1NYh4yh2P5U+PemDsBB/zqD8z/Zqml76s0=
maunium.net/go/mautrix v0.22.1-0.20241118181122-363fdfa3b227/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM=
maunium.net/go/mautrix v0.22.1-0.20241120171618-4cd2bb62ff01 h1:8zrcggLi5IyHO9oBxjejbRyvgT0ePEwYLdFCdtohDnI=
maunium.net/go/mautrix v0.22.1-0.20241120171618-4cd2bb62ff01/go.mod h1:oqwf9WYC/brqucM+heYk4gX11O59nP+ljvyxVhndFIM=
mvdan.cc/xurls/v2 v2.5.0 h1:lyBNOm8Wo71UknhUs4QTFUNNMyxy2JEIaKKo0RWOh+8=
mvdan.cc/xurls/v2 v2.5.0/go.mod h1:yQgaGQ1rFtJUzkmKiHYSSfuQxqfYmd//X6PxvholpeE=

View file

@ -33,7 +33,7 @@ import (
"go.mau.fi/gomuks/pkg/hicli"
)
func writeCmd(ctx context.Context, conn *websocket.Conn, cmd *hicli.JSONCommand) error {
func writeCmd[T any](ctx context.Context, conn *websocket.Conn, cmd *hicli.JSONCommandCustom[T]) error {
writer, err := conn.Writer(ctx, websocket.MessageText)
if err != nil {
return err
@ -188,17 +188,20 @@ func (gmx *Gomuks) HandleWebsocket(w http.ResponseWriter, r *http.Request) {
log.Trace().Int64("req_id", cmd.RequestID).Msg("Sent response to command")
}
}
initData, initErr := json.Marshal(gmx.Client.State())
if initErr != nil {
log.Err(initErr).Msg("Failed to marshal init message")
return
}
initErr = writeCmd(ctx, conn, &hicli.JSONCommand{
initErr := writeCmd(ctx, conn, &hicli.JSONCommandCustom[*hicli.ClientState]{
Command: "client_state",
Data: initData,
Data: gmx.Client.State(),
})
if initErr != nil {
log.Err(initErr).Msg("Failed to write init message")
log.Err(initErr).Msg("Failed to write init client state message")
return
}
initErr = writeCmd(ctx, conn, &hicli.JSONCommandCustom[*hicli.SyncStatus]{
Command: "sync_status",
Data: gmx.Client.SyncStatus.Load(),
})
if initErr != nil {
log.Err(initErr).Msg("Failed to write init sync status message")
return
}
go sendImageAuthToken()

View file

@ -7,6 +7,7 @@
package hicli
import (
"go.mau.fi/util/jsontime"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
@ -38,6 +39,21 @@ func (c *SyncComplete) IsEmpty() bool {
return len(c.Rooms) == 0 && len(c.LeftRooms) == 0 && len(c.AccountData) == 0
}
type SyncStatusType string
const (
SyncStatusOK SyncStatusType = "ok"
SyncStatusWaiting SyncStatusType = "waiting"
SyncStatusErrored SyncStatusType = "errored"
)
type SyncStatus struct {
Type SyncStatusType `json:"type"`
Error string `json:"error,omitempty"`
ErrorCount int `json:"error_count"`
LastSync jsontime.UnixMilli `json:"last_sync,omitempty"`
}
type EventsDecrypted struct {
RoomID id.RoomID `json:"room_id"`
PreviewEventRowID database.EventRowID `json:"preview_event_rowid,omitempty"`

View file

@ -45,6 +45,9 @@ type HiClient struct {
KeyBackupKey *backup.MegolmBackupKey
PushRules atomic.Pointer[pushrules.PushRuleset]
SyncStatus atomic.Pointer[SyncStatus]
syncErrors int
lastSync time.Time
EventHandler func(evt any)
@ -87,6 +90,7 @@ func New(rawDB, cryptoDB *dbutil.Database, log zerolog.Logger, pickleKey []byte,
EventHandler: evtHandler,
}
c.SyncStatus.Store(syncWaiting)
c.ClientStore = &database.ClientStateStore{Database: db}
c.Client = &mautrix.Client{
UserAgent: mautrix.DefaultUserAgent,
@ -243,8 +247,10 @@ func (h *HiClient) Sync() {
log.Info().Msg("Starting syncing")
err := h.Client.SyncWithContext(ctx)
if err != nil && ctx.Err() == nil {
h.markSyncErrored(err)
log.Err(err).Msg("Fatal error in syncer")
} else {
h.SyncStatus.Store(syncWaiting)
log.Info().Msg("Syncing stopped")
}
}

View file

@ -16,12 +16,14 @@ import (
"go.mau.fi/util/exerrors"
)
type JSONCommand struct {
type JSONCommandCustom[T any] struct {
Command string `json:"command"`
RequestID int64 `json:"request_id"`
Data json.RawMessage `json:"data"`
Data T `json:"data"`
}
type JSONCommand = JSONCommandCustom[json.RawMessage]
type JSONEventHandler func(*JSONCommand)
var outgoingEventCounter atomic.Int64
@ -31,6 +33,8 @@ func (jeh JSONEventHandler) HandleEvent(evt any) {
switch evt.(type) {
case *SyncComplete:
command = "sync_complete"
case *SyncStatus:
command = "sync_status"
case *EventsDecrypted:
command = "events_decrypted"
case *Typing:

View file

@ -39,6 +39,28 @@ type syncContext struct {
evt *SyncComplete
}
func (h *HiClient) markSyncErrored(err error) {
stat := &SyncStatus{
Type: SyncStatusErrored,
Error: err.Error(),
ErrorCount: h.syncErrors,
LastSync: jsontime.UM(h.lastSync),
}
h.SyncStatus.Store(stat)
h.EventHandler(stat)
}
var (
syncOK = &SyncStatus{Type: SyncStatusOK}
syncWaiting = &SyncStatus{Type: SyncStatusWaiting}
)
func (h *HiClient) markSyncOK() {
if h.SyncStatus.Swap(syncOK) != syncOK {
h.EventHandler(syncOK)
}
}
func (h *HiClient) preProcessSyncResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
log := zerolog.Ctx(ctx)
postponedToDevices := resp.ToDevice.Events[:0]

View file

@ -27,6 +27,7 @@ const (
func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync, since string) error {
c := (*HiClient)(h)
c.lastSync = time.Now()
ctx = context.WithValue(ctx, syncContextKey, &syncContext{evt: &SyncComplete{
Rooms: make(map[id.RoomID]*SyncRoom, len(resp.Rooms.Join)),
LeftRooms: make([]id.RoomID, 0, len(resp.Rooms.Leave)),
@ -42,12 +43,21 @@ func (h *hiSyncer) ProcessResponse(ctx context.Context, resp *mautrix.RespSync,
return err
}
c.postProcessSyncResponse(ctx, resp, since)
c.syncErrors = 0
c.markSyncOK()
return nil
}
func (h *hiSyncer) OnFailedSync(_ *mautrix.RespSync, err error) (time.Duration, error) {
(*HiClient)(h).Log.Err(err).Msg("Sync failed, retrying in 1 second")
return 1 * time.Second, nil
c := (*HiClient)(h)
c.syncErrors++
delay := 1 * time.Second
if c.syncErrors > 5 {
delay = max(time.Duration(c.syncErrors)*time.Second, 30*time.Second)
}
c.markSyncErrored(err)
c.Log.Err(err).Dur("retry_in", delay).Msg("Sync failed")
return delay, nil
}
func (h *hiSyncer) GetFilterJSON(_ id.UserID) *mautrix.Filter {

View file

@ -13,7 +13,7 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { CachedEventDispatcher } from "../util/eventdispatcher.ts"
import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
import RPCClient, { SendMessageParams } from "./rpc.ts"
import { RoomStateStore, StateStore } from "./statestore"
import type {
@ -25,11 +25,13 @@ import type {
RPCEvent,
RoomID,
RoomStateGUID,
SyncStatus,
UserID,
} from "./types"
export default class Client {
readonly state = new CachedEventDispatcher<ClientState>()
readonly syncStatus = new NonNullCachedEventDispatcher<SyncStatus>({ type: "waiting", error_count: 0 })
readonly store = new StateStore()
#stateRequests: RoomStateGUID[] = []
#stateRequestQueued = false
@ -82,6 +84,8 @@ export default class Client {
#handleEvent = (ev: RPCEvent) => {
if (ev.command === "client_state") {
this.state.emit(ev.data)
} else if (ev.command === "sync_status") {
this.syncStatus.emit(ev.data)
} else if (ev.command === "sync_complete") {
this.store.applySync(ev.data)
} else if (ev.command === "events_decrypted") {

View file

@ -107,8 +107,20 @@ export interface ClientStateEvent extends RPCCommand<ClientState> {
command: "client_state"
}
export interface SyncStatus {
type: "ok" | "waiting" | "errored"
error?: string
error_count: number
last_sync?: number
}
export interface SyncStatusEvent extends RPCCommand<SyncStatus> {
command: "sync_status"
}
export type RPCEvent =
ClientStateEvent |
SyncStatusEvent |
TypingEvent |
SendCompleteEvent |
EventsDecryptedEvent |

View file

@ -47,3 +47,22 @@ main.matrix-main {
grid-area: rh2;
}
}
div.sync-status {
position: fixed;
top: 1rem;
left: 50%;
transform: translate(-50%, 0);
padding: 1.5rem;
background-color: var(--background-color);
border: 1px solid var(--border-color);
border-radius: .5rem;
display: flex;
gap: 1rem;
&.errored {
border: 2px solid var(--error-color);
color: var(--error-color);
font-weight: bold;
}
}

View file

@ -13,10 +13,12 @@
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useReducer, useState } from "react"
import { JSX, use, useEffect, useInsertionEffect, useLayoutEffect, useMemo, useReducer, useState } from "react"
import { SyncLoader } from "react-spinners"
import Client from "@/api/client.ts"
import { RoomStateStore } from "@/api/statestore"
import type { RoomID } from "@/api/types"
import { useEventAsState } from "@/util/eventdispatcher.ts"
import ClientContext from "./ClientContext.ts"
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
import StylePreferences from "./StylePreferences.tsx"
@ -102,10 +104,13 @@ class ContextFields implements MainScreenContextFields {
closeRightPanel = () => this.setRightPanel(null)
}
const SYNC_ERROR_HIDE_DELAY = 30 * 1000
const MainScreen = () => {
const [activeRoom, directSetActiveRoom] = useState<RoomStateStore | null>(null)
const [rightPanel, setRightPanel] = useReducer(rpReducer, null)
const client = use(ClientContext)!
const syncStatus = useEventAsState(client.syncStatus)
const context = useMemo(
() => new ContextFields(setRightPanel, directSetActiveRoom, client),
[client],
@ -144,6 +149,21 @@ const MainScreen = () => {
if (rightPanel) {
classNames.push("right-panel-open")
}
let syncLoader: JSX.Element | null = null
if (syncStatus.type === "waiting") {
syncLoader = <div className="sync-status waiting">
<SyncLoader color="var(--primary-color)"/>
Waiting for first sync...
</div>
} else if (
syncStatus.type === "errored"
&& (syncStatus.error_count > 2 || (syncStatus.last_sync ?? 0) + SYNC_ERROR_HIDE_DELAY < Date.now())
) {
syncLoader = <div className="sync-status errored" title={syncStatus.error}>
<SyncLoader color="var(--error-color)"/>
Sync is failing
</div>
}
return <MainScreenContext value={context}>
<ModalWrapper>
<StylePreferences client={client} activeRoom={activeRoom}/>
@ -162,6 +182,7 @@ const MainScreen = () => {
{rightPanel && <RightPanel {...rightPanel}/>}
</>}
</main>
{syncLoader}
</ModalWrapper>
</MainScreenContext>
}