Lots of changes (see extended)

* Change the display of presence setter to use a list of the three statuses for accessibility, rather than the (arguably cooler) grid of three buttons
* Make the status message setter fit better with the gomuks theme
* added a `clear` button in addition to the status setter
* Seperated the edit/display into seperate components, and put them in their own file
* Added the update_presence event  to the backend
* Presence indicators are now customisable SVG circles, instead of emojis
* some more stuff that I probably forgot
This commit is contained in:
nexy7574 2024-12-22 23:06:58 +00:00
parent 1d46df7c0f
commit 1baf28b7d9
No known key found for this signature in database
10 changed files with 230 additions and 56 deletions

View file

@ -82,3 +82,9 @@ type ClientState struct {
DeviceID id.DeviceID `json:"device_id,omitempty"` DeviceID id.DeviceID `json:"device_id,omitempty"`
HomeserverURL string `json:"homeserver_url,omitempty"` HomeserverURL string `json:"homeserver_url,omitempty"`
} }
type UpdatePresence struct {
Presence event.Presence `json:"presence"`
StatusMsg string `json:"status_msg,omitempty"`
UserID id.UserID `json:"user_id"`
}

View file

@ -21,6 +21,11 @@ import (
"go.mau.fi/gomuks/pkg/hicli/database" "go.mau.fi/gomuks/pkg/hicli/database"
) )
type SetPresenceParams struct {
Presence event.Presence `json:"presence"`
StatusMsg string `json:"status_msg,omitempty"`
}
func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any, error) { func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any, error) {
switch req.Command { switch req.Command {
case "get_state": case "get_state":
@ -92,7 +97,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
}) })
case "set_presence": case "set_presence":
return unmarshalAndCall(req.Data, func(params *mautrix.ReqPresence) (bool, error) { return unmarshalAndCall(req.Data, func(params *mautrix.ReqPresence) (bool, error) {
return true, h.Client.SetPresence(ctx, params) return true, h.Client.SetPresence(ctx, *params)
}) })
case "get_mutual_rooms": case "get_mutual_rooms":
return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) { return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) {

View file

@ -42,6 +42,8 @@ func EventTypeName(evt any) string {
return "send_complete" return "send_complete"
case *ClientState: case *ClientState:
return "client_state" return "client_state"
case UpdatePresence:
return "update_presence"
default: default:
panic(fmt.Errorf("unknown event type %T", evt)) panic(fmt.Errorf("unknown event type %T", evt))
} }

View file

@ -169,6 +169,20 @@ func (h *HiClient) processSyncResponse(ctx context.Context, resp *mautrix.RespSy
return fmt.Errorf("failed to process left room %s: %w", roomID, err) return fmt.Errorf("failed to process left room %s: %w", roomID, err)
} }
} }
for _, presenceEvent := range resp.Presence.Events {
presenceEvent.Type.Class = event.EphemeralEventPresence.Class
if presenceEvent.Content.ParseRaw(event.EphemeralEventPresence) != nil {
zerolog.Ctx(ctx).Warn().Stringer("event_id", presenceEvent.ID).Msg(fmt.Sprintf("Failed to parse presence event content: %s", presenceEvent.Content.VeryRaw))
continue
}
body := presenceEvent.Content.AsPresence()
presence := UpdatePresence{
Presence: body.Presence,
StatusMsg: body.StatusMessage,
UserID: presenceEvent.Sender,
}
h.EventHandler(presence)
}
h.Account.NextBatch = resp.NextBatch h.Account.NextBatch = resp.NextBatch
err = h.DB.Account.PutNextBatch(ctx, h.Account.UserID, resp.NextBatch) err = h.DB.Account.PutNextBatch(ctx, h.Account.UserID, resp.NextBatch)
if err != nil { if err != nil {

View file

@ -186,7 +186,7 @@ export default abstract class RPCClient {
} }
setPresence(presence: Presence): Promise<boolean> { setPresence(presence: Presence): Promise<boolean> {
return this.request("set_presence", { presence }) return this.request("set_presence", presence)
} }
getMutualRooms(user_id: UserID): Promise<RoomID[]> { getMutualRooms(user_id: UserID): Promise<RoomID[]> {

View file

@ -46,6 +46,16 @@ export interface TypingEvent extends BaseRPCCommand<TypingEventData> {
command: "typing" command: "typing"
} }
export interface UpdatePresenceEventData {
user_id: UserID
presence: "online" | "offline" | "unavailable"
status_msg?: string
}
export interface UpdatePresenceEvent extends BaseRPCCommand<UpdatePresenceEventData> {
command: "update_presence"
}
export interface SendCompleteData { export interface SendCompleteData {
event: RawDBEvent event: RawDBEvent
error: string | null error: string | null
@ -155,6 +165,7 @@ export type RPCEvent =
SyncCompleteEvent | SyncCompleteEvent |
ImageAuthTokenEvent | ImageAuthTokenEvent |
InitCompleteEvent | InitCompleteEvent |
RunIDEvent RunIDEvent |
UpdatePresenceEvent
export type RPCCommand = RPCEvent | ResponseCommand | ErrorCommand export type RPCCommand = RPCEvent | ResponseCommand | ErrorCommand

View file

@ -84,6 +84,10 @@
--timeline-horizontal-padding: 1.5rem; --timeline-horizontal-padding: 1.5rem;
--timeline-status-size: 4rem; --timeline-status-size: 4rem;
--presence-online: green;
--presence-offline: grey;
--presence-unavailable: red;
@media screen and (max-width: 45rem) { @media screen and (max-width: 45rem) {
--timeline-horizontal-padding: .5rem; --timeline-horizontal-padding: .5rem;
--timeline-status-size: 2.25rem; --timeline-status-size: 2.25rem;

View file

@ -86,7 +86,22 @@ div.right-panel-content.user {
div.presence { div.presence {
text-align: center; text-align: center;
font-size: 1.125rem; font-size: 1.125rem;
}
svg.presence-indicator {
width: 1em;
height: 1em;
border-radius: 50%;
vertical-align: middle;
}
svg.presence-online {
fill: var(--presence-online);
}
svg.presence-offline {
fill: var(--presence-offline);
}
svg.presence-unavailable {
fill: var(--presence-unavailable);
} }
div.statusmessage { div.statusmessage {
@ -99,7 +114,49 @@ div.right-panel-content.user {
div.presencesetter { div.presencesetter {
display: grid; display: grid;
grid-template-columns: 1fr;
grid-template-rows: repeat(3, 1fr);
justify-content: left;
justify-items: left;
gap: .5rem;
}
div.presencesetter > button {
width: 100%;
text-align: left;
padding: .5rem 0;
justify-content: left;
}
div.statussetter > form {
display: grid;
}
form.canclear {
grid-template-columns: repeat(4, 1fr);
}
form.cannotclear {
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(3, 1fr);
& input[type="submit"] {
grid-column: 1 / span 3;
}
}
div.statussetter > form > input[type="text"] {
grid-column: 1 / span 2;
border: none;
transition: border-bottom .2s;
border-bottom: 1px solid var(--blockquote-border-color);
}
div.statussetter > form > input[type="text"]:focus {
border-bottom: 1px solid var(--primary-color);
outline: none;
}
div.statussetter > form > input[type="submit"]:hover {
background-color: var(--primary-color);
} }
div.userid, div.displayname { div.userid, div.displayname {

View file

@ -26,17 +26,12 @@ import DeviceList from "./UserInfoDeviceList.tsx"
import UserInfoError from "./UserInfoError.tsx" import UserInfoError from "./UserInfoError.tsx"
import MutualRooms from "./UserInfoMutualRooms.tsx" import MutualRooms from "./UserInfoMutualRooms.tsx"
import { ErrorResponse } from "@/api/rpc.ts" import { ErrorResponse } from "@/api/rpc.ts"
import { UserPresence } from "./UserPresence.tsx"
interface UserInfoProps { interface UserInfoProps {
userID: UserID userID: UserID
} }
const PresenceEmojis = {
online: "🟢",
offline: "⚫",
unavailable: "🔴",
}
const UserInfo = ({ userID }: UserInfoProps) => { const UserInfo = ({ userID }: UserInfoProps) => {
const client = use(ClientContext)! const client = use(ClientContext)!
const roomCtx = use(RoomContext) const roomCtx = use(RoomContext)
@ -44,7 +39,6 @@ const UserInfo = ({ userID }: UserInfoProps) => {
const memberEvt = useRoomMember(client, roomCtx?.store, userID) const memberEvt = useRoomMember(client, roomCtx?.store, userID)
const member = (memberEvt?.content ?? null) as MemberEventContent | null const member = (memberEvt?.content ?? null) as MemberEventContent | null
const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null) const [globalProfile, setGlobalProfile] = useState<UserProfile | null>(null)
const [presence, setPresence] = useState<Presence | null>(null)
const [errors, setErrors] = useState<string[] | null>(null) const [errors, setErrors] = useState<string[] | null>(null)
useEffect(() => { useEffect(() => {
setErrors(null) setErrors(null)
@ -53,28 +47,8 @@ const UserInfo = ({ userID }: UserInfoProps) => {
setGlobalProfile, setGlobalProfile,
err => setErrors([`${err}`]), err => setErrors([`${err}`]),
) )
client.rpc.getPresence(userID).then(
setPresence,
err => {
// A 404 is to be expected if the user has not federated presence.
if (err instanceof ErrorResponse && err.message.startsWith("M_NOT_FOUND")) {
setPresence(null)
} else {
if(errors) {setErrors([...errors, `${err}`])}
else {setErrors([`${err}`])}
}
}
)
}, [roomCtx, userID, client]) }, [roomCtx, userID, client])
const sendNewPresence = (newPresence: Presence) => {
console.log("Setting new presence", newPresence)
client.rpc.setPresence(newPresence).then(
() => setPresence(newPresence),
err => setErrors((errors && [...errors, `${err}`] || [`${err}`])),
)
}
const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID) const displayname = member?.displayname || globalProfile?.displayname || getLocalpart(userID)
return <> return <>
<div className="avatar-container"> <div className="avatar-container">
@ -91,17 +65,7 @@ const UserInfo = ({ userID }: UserInfoProps) => {
</div> </div>
<div className="displayname" title={displayname}>{displayname}</div> <div className="displayname" title={displayname}>{displayname}</div>
<div className="userid" title={userID}>{userID}</div> <div className="userid" title={userID}>{userID}</div>
{presence && ( <UserPresence client={client} userID={userID}/>
<>
<div className="presence" title={presence.presence}>{PresenceEmojis[presence.presence]} {presence.presence}</div>
{
presence.status_msg && (
<div className="statusmessage" title={"Status message"}><blockquote>{presence.status_msg}</blockquote></div>
)
}
</>
)
}
<hr/> <hr/>
{userID !== client.userID && <> {userID !== client.userID && <>
<MutualRooms client={client} userID={userID}/> <MutualRooms client={client} userID={userID}/>
@ -109,20 +73,6 @@ const UserInfo = ({ userID }: UserInfoProps) => {
</>} </>}
<DeviceList client={client} room={roomCtx?.store} userID={userID}/> <DeviceList client={client} room={roomCtx?.store} userID={userID}/>
<hr/> <hr/>
{userID === client.userID && <>
<h3>Set presence</h3>
<div className="presencesetter">
<button title="Set presence to online" onClick={() => sendNewPresence({...(presence || {}), "presence": "online"})} type="button">{PresenceEmojis["online"]}</button>
<button title="Set presence to unavailable" onClick={() => sendNewPresence({...(presence || {}), "presence": "unavailable"})} type="button">{PresenceEmojis["unavailable"]}</button>
<button title="Set presence to offline" onClick={() => sendNewPresence({...(presence || {}), "presence": "offline"})} type="button">{PresenceEmojis["offline"]}</button>
</div>
<div className="statussetter">
<form onSubmit={(e) => {e.preventDefault(); sendNewPresence({...(presence || {"presence": "offline"}), "status_msg": ((e.currentTarget as HTMLFormElement).children[0] as HTMLInputElement).value})}}>
<input type="text" placeholder="Status message" defaultValue={presence?.status_msg || ""}/><button title="Set status message" onClick={() => alert("Set status message")} type="submit">Set</button>
</form>
</div>
<hr/>
</>}
{errors?.length ? <> {errors?.length ? <>
<UserInfoError errors={errors}/> <UserInfoError errors={errors}/>
<hr/> <hr/>

View file

@ -0,0 +1,125 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2024 Tulir Asokan
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// 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 { MouseEvent, useEffect, useState } from "react"
import { UserID, Presence, RPCEvent } from "@/api/types"
import { ErrorResponse } from "@/api/rpc.ts"
import Client from "@/api/client"
interface UserPresenceProps {
client: Client,
userID: UserID
}
interface EditUserPresenceProps {
client: Client,
presence: Presence,
setter: (presence: Presence) => void
}
const PresenceEmojis = {
"online": <svg className="presence-indicator presence-online" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><circle cx="16" cy="16" r="50" /></svg>,
"offline": <svg className="presence-indicator presence-offline" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><circle cx="16" cy="16" r="50" /></svg>,
"unavailable": <svg className="presence-indicator presence-unavailable" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><circle cx="16" cy="16" r="50" /></svg>,
}
export const UserPresence = ({ client, userID }: UserPresenceProps) => {
const [presence, setPresence] = useState<Presence | null>(null)
const [errors, setErrors] = useState<string[] | null>(null)
client.rpc.event.listen((event: RPCEvent) => {
if (event.command === "update_presence" && event.data.user_id === userID) {
setPresence({
"presence": event.data.presence,
"status_msg": event.data.status_msg || null
})
}
})
useEffect(() => {
client.rpc.getPresence(userID).then(
setPresence,
err => {
// A 404 is to be expected if the user has not federated presence.
if (err instanceof ErrorResponse && err.message.startsWith("M_NOT_FOUND")) {
setPresence(null)
} else {
errors?.length ? setErrors([...errors, `${err}`]) : setErrors([`${err}`])
}
}
)
}, [client, userID])
if(!presence) return null;
return (
<>
<DisplayUserPresence presence={presence} />
{
userID === client.userID && <EditUserPresence client={client} presence={presence} setter={setPresence}/>
}
</>
)
}
export const DisplayUserPresence = ({ presence }: { presence: Presence | null }) => {
if(!presence) return null
return (
<>
<div className="presence" title={presence.presence}>{PresenceEmojis[presence.presence]} {presence.presence}</div>
{
presence.status_msg && (
<div className="statusmessage" title={"Status message"}><blockquote>{presence.status_msg}</blockquote></div>
)
}
</>
)
}
export const EditUserPresence = ({ client, presence, setter }: EditUserPresenceProps) => {
const sendNewPresence = (newPresence: Presence) => {
client.rpc.setPresence(newPresence).then(
() => setter(newPresence),
err => console.error(err),
)
}
const clearStatusMessage = (e: MouseEvent<HTMLButtonElement>) => {
let p = presence || {"presence": "offline"}
if(p.status_msg) {
delete p.status_msg
}
const textInputElement = e.currentTarget.parentElement?.querySelector("input[type=text]") as HTMLInputElement
client.rpc.setPresence(p).then(
() => {setter(p); if(textInputElement) textInputElement.value = ""},
err => console.error(err),
)
}
return (
<>
<h4>Set presence</h4>
<div className="presencesetter">
<button title="Set presence to online" onClick={() => sendNewPresence({...(presence || {}), "presence": "online"})} type="button">{PresenceEmojis["online"]} Online</button>
<button title="Set presence to unavailable" onClick={() => sendNewPresence({...(presence || {}), "presence": "unavailable"})} type="button">{PresenceEmojis["unavailable"]} Unavailable</button>
<button title="Set presence to offline" onClick={() => sendNewPresence({...(presence || {}), "presence": "offline"})} type="button">{PresenceEmojis["offline"]} Offline</button>
</div>
<p></p>
<div className="statussetter">
<form className={presence?.status_msg ? "canclear" : "cannotclear"} onSubmit={(e) => {e.preventDefault(); sendNewPresence({...(presence || {}), "status_msg": (e.currentTarget[0] as HTMLInputElement).value})}}>
<input type="text" placeholder="Status message" defaultValue={presence?.status_msg || ""}/>
{presence?.status_msg && <button title="Clear status" type="button" onClick={(e) => clearStatusMessage(e)}>Clear</button>}
<button title="Set status message" type="submit">Set</button>
</form>
</div>
</>
)
}