mirror of
https://github.com/tulir/gomuks.git
synced 2025-04-20 10:33:41 -05:00
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:
parent
1d46df7c0f
commit
1baf28b7d9
10 changed files with 230 additions and 56 deletions
|
@ -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"`
|
||||
}
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -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))
|
||||
}
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -186,7 +186,7 @@ export default abstract class RPCClient {
|
|||
}
|
||||
|
||||
setPresence(presence: Presence): Promise<boolean> {
|
||||
return this.request("set_presence", { presence })
|
||||
return this.request("set_presence", presence)
|
||||
}
|
||||
|
||||
getMutualRooms(user_id: UserID): Promise<RoomID[]> {
|
||||
|
|
|
@ -46,6 +46,16 @@ export interface TypingEvent extends BaseRPCCommand<TypingEventData> {
|
|||
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 {
|
||||
event: RawDBEvent
|
||||
error: string | null
|
||||
|
@ -155,6 +165,7 @@ export type RPCEvent =
|
|||
SyncCompleteEvent |
|
||||
ImageAuthTokenEvent |
|
||||
InitCompleteEvent |
|
||||
RunIDEvent
|
||||
RunIDEvent |
|
||||
UpdatePresenceEvent
|
||||
|
||||
export type RPCCommand = RPCEvent | ResponseCommand | ErrorCommand
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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<UserProfile | null>(null)
|
||||
const [presence, setPresence] = useState<Presence | null>(null)
|
||||
const [errors, setErrors] = useState<string[] | null>(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 <>
|
||||
<div className="avatar-container">
|
||||
|
@ -91,17 +65,7 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
|||
</div>
|
||||
<div className="displayname" title={displayname}>{displayname}</div>
|
||||
<div className="userid" title={userID}>{userID}</div>
|
||||
{presence && (
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
</>
|
||||
)
|
||||
}
|
||||
<UserPresence client={client} userID={userID}/>
|
||||
<hr/>
|
||||
{userID !== client.userID && <>
|
||||
<MutualRooms client={client} userID={userID}/>
|
||||
|
@ -109,20 +73,6 @@ const UserInfo = ({ userID }: UserInfoProps) => {
|
|||
</>}
|
||||
<DeviceList client={client} room={roomCtx?.store} userID={userID}/>
|
||||
<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 ? <>
|
||||
<UserInfoError errors={errors}/>
|
||||
<hr/>
|
||||
|
|
125
web/src/ui/rightpanel/UserPresence.tsx
Normal file
125
web/src/ui/rightpanel/UserPresence.tsx
Normal 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>
|
||||
</>
|
||||
)
|
||||
}
|
Loading…
Add table
Reference in a new issue