From 1baf28b7d9d5efc466f218c4d87115fb536f215e Mon Sep 17 00:00:00 2001 From: nexy7574 Date: Sun, 22 Dec 2024 23:06:58 +0000 Subject: [PATCH] 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 --- pkg/hicli/events.go | 6 ++ pkg/hicli/json-commands.go | 7 +- pkg/hicli/json.go | 2 + pkg/hicli/sync.go | 14 +++ web/src/api/rpc.ts | 2 +- web/src/api/types/hievents.ts | 13 ++- web/src/index.css | 4 + web/src/ui/rightpanel/RightPanel.css | 59 +++++++++++- web/src/ui/rightpanel/UserInfo.tsx | 54 +---------- web/src/ui/rightpanel/UserPresence.tsx | 125 +++++++++++++++++++++++++ 10 files changed, 230 insertions(+), 56 deletions(-) create mode 100644 web/src/ui/rightpanel/UserPresence.tsx diff --git a/pkg/hicli/events.go b/pkg/hicli/events.go index 2bd8534..0c4f1cf 100644 --- a/pkg/hicli/events.go +++ b/pkg/hicli/events.go @@ -82,3 +82,9 @@ type ClientState struct { DeviceID id.DeviceID `json:"device_id,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"` +} diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index 5e43bf8..55c21f0 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -21,6 +21,11 @@ import ( "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) { switch req.Command { case "get_state": @@ -92,7 +97,7 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any }) case "set_presence": 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": return unmarshalAndCall(req.Data, func(params *getProfileParams) ([]id.RoomID, error) { diff --git a/pkg/hicli/json.go b/pkg/hicli/json.go index 5c1274a..ac75954 100644 --- a/pkg/hicli/json.go +++ b/pkg/hicli/json.go @@ -42,6 +42,8 @@ func EventTypeName(evt any) string { return "send_complete" case *ClientState: return "client_state" + case UpdatePresence: + return "update_presence" default: panic(fmt.Errorf("unknown event type %T", evt)) } diff --git a/pkg/hicli/sync.go b/pkg/hicli/sync.go index fd6aea1..1b3232d 100644 --- a/pkg/hicli/sync.go +++ b/pkg/hicli/sync.go @@ -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) } } + 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 err = h.DB.Account.PutNextBatch(ctx, h.Account.UserID, resp.NextBatch) if err != nil { diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 749a3ad..48903e2 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -186,7 +186,7 @@ export default abstract class RPCClient { } setPresence(presence: Presence): Promise { - return this.request("set_presence", { presence }) + return this.request("set_presence", presence) } getMutualRooms(user_id: UserID): Promise { diff --git a/web/src/api/types/hievents.ts b/web/src/api/types/hievents.ts index f05296e..9ae9a62 100644 --- a/web/src/api/types/hievents.ts +++ b/web/src/api/types/hievents.ts @@ -46,6 +46,16 @@ export interface TypingEvent extends BaseRPCCommand { command: "typing" } +export interface UpdatePresenceEventData { + user_id: UserID + presence: "online" | "offline" | "unavailable" + status_msg?: string +} + +export interface UpdatePresenceEvent extends BaseRPCCommand { + command: "update_presence" +} + export interface SendCompleteData { event: RawDBEvent error: string | null @@ -155,6 +165,7 @@ export type RPCEvent = SyncCompleteEvent | ImageAuthTokenEvent | InitCompleteEvent | - RunIDEvent + RunIDEvent | + UpdatePresenceEvent export type RPCCommand = RPCEvent | ResponseCommand | ErrorCommand diff --git a/web/src/index.css b/web/src/index.css index 9d7f938..f9d96b3 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -84,6 +84,10 @@ --timeline-horizontal-padding: 1.5rem; --timeline-status-size: 4rem; + --presence-online: green; + --presence-offline: grey; + --presence-unavailable: red; + @media screen and (max-width: 45rem) { --timeline-horizontal-padding: .5rem; --timeline-status-size: 2.25rem; diff --git a/web/src/ui/rightpanel/RightPanel.css b/web/src/ui/rightpanel/RightPanel.css index ea50f99..5edffe0 100644 --- a/web/src/ui/rightpanel/RightPanel.css +++ b/web/src/ui/rightpanel/RightPanel.css @@ -86,7 +86,22 @@ div.right-panel-content.user { div.presence { text-align: center; 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 { @@ -99,7 +114,49 @@ div.right-panel-content.user { div.presencesetter { 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); + + & 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 { diff --git a/web/src/ui/rightpanel/UserInfo.tsx b/web/src/ui/rightpanel/UserInfo.tsx index 123247a..43eeea4 100644 --- a/web/src/ui/rightpanel/UserInfo.tsx +++ b/web/src/ui/rightpanel/UserInfo.tsx @@ -26,17 +26,12 @@ import DeviceList from "./UserInfoDeviceList.tsx" import UserInfoError from "./UserInfoError.tsx" import MutualRooms from "./UserInfoMutualRooms.tsx" import { ErrorResponse } from "@/api/rpc.ts" +import { UserPresence } from "./UserPresence.tsx" interface UserInfoProps { userID: UserID } -const PresenceEmojis = { - online: "🟢", - offline: "⚫", - unavailable: "🔴", -} - const UserInfo = ({ userID }: UserInfoProps) => { const client = use(ClientContext)! const roomCtx = use(RoomContext) @@ -44,7 +39,6 @@ const UserInfo = ({ userID }: UserInfoProps) => { const memberEvt = useRoomMember(client, roomCtx?.store, userID) const member = (memberEvt?.content ?? null) as MemberEventContent | null const [globalProfile, setGlobalProfile] = useState(null) - const [presence, setPresence] = useState(null) const [errors, setErrors] = useState(null) useEffect(() => { setErrors(null) @@ -53,28 +47,8 @@ const UserInfo = ({ userID }: UserInfoProps) => { setGlobalProfile, 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]) - 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) return <>
@@ -91,17 +65,7 @@ const UserInfo = ({ userID }: UserInfoProps) => {
{displayname}
{userID}
- {presence && ( - <> -
{PresenceEmojis[presence.presence]} {presence.presence}
- { - presence.status_msg && ( -
{presence.status_msg}
- ) - } - - ) - } +
{userID !== client.userID && <> @@ -109,20 +73,6 @@ const UserInfo = ({ userID }: UserInfoProps) => { }
- {userID === client.userID && <> -

Set presence

-
- - - -
-
-
{e.preventDefault(); sendNewPresence({...(presence || {"presence": "offline"}), "status_msg": ((e.currentTarget as HTMLFormElement).children[0] as HTMLInputElement).value})}}> - -
-
-
- } {errors?.length ? <>
diff --git a/web/src/ui/rightpanel/UserPresence.tsx b/web/src/ui/rightpanel/UserPresence.tsx new file mode 100644 index 0000000..fc1a877 --- /dev/null +++ b/web/src/ui/rightpanel/UserPresence.tsx @@ -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 . +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": , + "offline": , + "unavailable": , +} + +export const UserPresence = ({ client, userID }: UserPresenceProps) => { + const [presence, setPresence] = useState(null) + const [errors, setErrors] = useState(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 ( + <> + + { + userID === client.userID && + } + + ) +} + +export const DisplayUserPresence = ({ presence }: { presence: Presence | null }) => { + if(!presence) return null + return ( + <> +
{PresenceEmojis[presence.presence]} {presence.presence}
+ { + presence.status_msg && ( +
{presence.status_msg}
+ ) + } + + ) +} + +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) => { + 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 ( + <> +

Set presence

+
+ + + +
+

+
+
{e.preventDefault(); sendNewPresence({...(presence || {}), "status_msg": (e.currentTarget[0] as HTMLInputElement).value})}}> + + {presence?.status_msg && } + +
+
+ + ) +}