1
0
Fork 0
forked from Mirrors/gomuks

web/rightpanel: add start DM button

Fixes #592
This commit is contained in:
Tulir Asokan 2025-02-25 21:02:46 +02:00
parent bdae0c416f
commit 2203c18e15
7 changed files with 200 additions and 35 deletions

View file

@ -223,6 +223,10 @@ func (h *HiClient) handleJSONCommand(ctx context.Context, req *JSONCommand) (any
return unmarshalAndCall(req.Data, func(params *database.PushRegistration) (bool, error) {
return true, h.DB.PushRegistration.Put(ctx, params)
})
case "create_room":
return unmarshalAndCall(req.Data, func(params *mautrix.ReqCreateRoom) (*mautrix.RespCreateRoom, error) {
return h.Client.CreateRoom(ctx, params)
})
default:
return nil, fmt.Errorf("unknown command %q", req.Command)
}

View file

@ -34,7 +34,9 @@ import type {
ReceiptType,
RelatesTo,
RelationType,
ReqCreateRoom,
ResolveAliasResponse,
RespCreateRoom,
RespOpenIDToken,
RespRoomJoin,
RoomAlias,
@ -277,4 +279,8 @@ export default abstract class RPCClient {
registerPush(reg: DBPushRegistration): Promise<boolean> {
return this.request("register_push", reg)
}
createRoom(request: ReqCreateRoom): Promise<RespCreateRoom> {
return this.request("create_room", request)
}
}

View file

@ -320,3 +320,29 @@ export interface RespOpenIDToken {
matrix_server_name: string
token_type: "Bearer"
}
export type RoomVisibility = "public" | "private"
export type RoomPreset = "private_chat" | "public_chat" | "trusted_private_chat"
export interface ReqCreateRoom {
visibility?: RoomVisibility
room_alias_name?: string
name?: string
topic?: string
invite?: UserID[]
preset?: RoomPreset
is_direct?: boolean
initial_state?: {
type: EventType
state_key?: string
content: Record<string, unknown>
}[]
room_version?: string
creation_content?: Record<string, unknown>
power_level_content_override?: Record<string, unknown>
"fi.mau.room_id"?: RoomID
}
export interface RespCreateRoom {
room_id: RoomID
}

1
web/src/icons/chat.svg Normal file
View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" height="24px" viewBox="0 -960 960 960" width="24px" fill="#5f6368"><path d="M240-400h320v-80H240v80Zm0-120h480v-80H240v80Zm0-120h480v-80H240v80ZM80-80v-720q0-33 23.5-56.5T160-880h640q33 0 56.5 23.5T880-800v480q0 33-23.5 56.5T800-240H240L80-80Zm126-240h594v-480H160v525l46-45Zm-46 0v-480 480Z"/></svg>

After

Width:  |  Height:  |  Size: 341 B

View file

@ -0,0 +1,102 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 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 { use, useMemo, useState } from "react"
import Client from "@/api/client.ts"
import { UserID } from "@/api/types"
import MainScreenContext from "../MainScreenContext.ts"
import ChatIcon from "@/icons/chat.svg?react"
const StartDMButton = ({ userID, client }: { userID: UserID; client: Client }) => {
const mainScreen = use(MainScreenContext)!
const [isCreating, setIsCreating] = useState(false)
const findExistingRoom = () => {
for (const room of client.store.rooms.values()) {
if (room.meta.current.dm_user_id === userID) {
return room.roomID
}
}
}
const existingRoom = useMemo(findExistingRoom, [userID, client])
const startDM = async () => {
if (existingRoom) {
mainScreen.setActiveRoom(existingRoom)
return
}
if (!window.confirm(`Are you sure you want to start a chat with ${userID}?`)) {
return
}
const existingRoomRelookup = findExistingRoom()
if (existingRoomRelookup) {
mainScreen.setActiveRoom(existingRoomRelookup)
return
}
try {
setIsCreating(true)
let shouldEncrypt = false
const initialState = []
try {
shouldEncrypt = (await client.rpc.trackUserDevices(userID)).devices.length > 0
if (shouldEncrypt) {
console.log("User has encryption devices, creating encrypted room")
initialState.push({
type: "m.room.encryption",
content: {
algorithm: "m.megolm.v1.aes-sha2",
},
})
}
} catch (err) {
console.warn("Failed to check user encryption status:", err)
}
// Create the room with encryption if needed
const response = await client.rpc.createRoom({
is_direct: true,
preset: "trusted_private_chat",
invite: [userID],
initial_state: initialState,
})
console.log("Created DM room:", response.room_id)
// FIXME this is a hacky way to work around the room taking time to come down /sync
setTimeout(() => {
mainScreen.setActiveRoom(response.room_id)
}, 1000)
} catch (err) {
console.error("Failed to create DM room:", err)
window.alert(`Failed to create DM room: ${err}`)
} finally {
setIsCreating(false)
}
}
return <button
className="moderation-action positive"
onClick={startDM}
disabled={isCreating}
>
<ChatIcon />
<span>{existingRoom ? "Go to DM" : isCreating ? "Creating..." : "Create DM"}</span>
</button>
}
export default StartDMButton

View file

@ -0,0 +1,55 @@
// gomuks - A Matrix client written in Go.
// Copyright (C) 2025 Nexus Nicholson
//
// 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 Client from "@/api/client.ts"
import { useAccountData } from "@/api/statestore"
import { IgnoredUsersEventContent } from "@/api/types"
import IgnoreIcon from "@/icons/block.svg?react"
const UserIgnoreButton = ({ userID, client }: { userID: string; client: Client }) => {
const ignoredUsers = useAccountData(client.store, "m.ignored_user_list") as IgnoredUsersEventContent | null
const isIgnored = Boolean(ignoredUsers?.ignored_users?.[userID])
const ignoreUser = () => {
if (!window.confirm(`Are you sure you want to ignore ${userID}?`)) {
return
}
const newIgnoredUsers = { ...(ignoredUsers || { ignored_users: {}}) }
newIgnoredUsers.ignored_users[userID] = {}
client.rpc.setAccountData("m.ignored_user_list", newIgnoredUsers).catch(err => {
console.error("Failed to ignore user", err)
window.alert(`Failed to ignore ${userID}: ${err}`)
})
}
const unignoreUser = () => {
const newIgnoredUsers = { ...(ignoredUsers || { ignored_users: {}}) }
delete newIgnoredUsers.ignored_users[userID]
client.rpc.setAccountData("m.ignored_user_list", newIgnoredUsers).catch(err => {
console.error("Failed to unignore user", err)
window.alert(`Failed to unignore ${userID}: ${err}`)
})
}
return (
<button
className={"moderation-action " + (isIgnored ? "positive" : "dangerous")}
onClick={isIgnored ? unignoreUser : ignoreUser}>
<IgnoreIcon/>
<span>{isIgnored ? "Unignore" : "Ignore"}</span>
</button>
)
}
export default UserIgnoreButton

View file

@ -15,12 +15,13 @@
// along with this program. If not, see <https://www.gnu.org/licenses/>.
import { use } from "react"
import Client from "@/api/client.ts"
import { RoomStateStore, useAccountData } from "@/api/statestore"
import { IgnoredUsersEventContent, MemDBEvent, MembershipAction } from "@/api/types"
import { RoomStateStore } from "@/api/statestore"
import { MemDBEvent, MembershipAction } from "@/api/types"
import { ModalContext } from "../modal"
import ConfirmWithMessageModal from "../timeline/menu/ConfirmWithMessageModal.tsx"
import { getPowerLevels } from "../timeline/menu/util.ts"
import IgnoreIcon from "@/icons/block.svg?react"
import StartDMButton from "./StartDMButton.tsx"
import UserIgnoreButton from "./UserIgnoreButton.tsx"
import BanIcon from "@/icons/gavel.svg?react"
import InviteIcon from "@/icons/person-add.svg?react"
import KickIcon from "@/icons/person-remove.svg?react"
@ -32,37 +33,6 @@ interface UserModerationProps {
member: MemDBEvent | null;
}
const UserIgnoreButton = ({ userID, client }: { userID: string; client: Client }) => {
const ignoredUsers = useAccountData(client.store, "m.ignored_user_list") as IgnoredUsersEventContent | null
const isIgnored = Boolean(ignoredUsers?.ignored_users?.[userID])
const ignoreUser = () => {
const newIgnoredUsers = { ...(ignoredUsers || { ignored_users: {}}) }
newIgnoredUsers.ignored_users[userID] = {}
client.rpc.setAccountData("m.ignored_user_list", newIgnoredUsers).catch(err => {
console.error("Failed to ignore user", err)
window.alert(`Failed to ignore ${userID}: ${err}`)
})
}
const unignoreUser = () => {
const newIgnoredUsers = { ...(ignoredUsers || { ignored_users: {}}) }
delete newIgnoredUsers.ignored_users[userID]
client.rpc.setAccountData("m.ignored_user_list", newIgnoredUsers).catch(err => {
console.error("Failed to unignore user", err)
window.alert(`Failed to unignore ${userID}: ${err}`)
})
}
return (
<button
className={"moderation-action " + (isIgnored ? "positive" : "dangerous")}
onClick={isIgnored ? unignoreUser : ignoreUser}>
<IgnoreIcon/>
<span>{isIgnored ? "Unignore" : "Ignore"}</span>
</button>
)
}
const UserModeration = ({ userID, client, member, room }: UserModerationProps) => {
const openModal = use(ModalContext)
const hasPL = (action: "invite" | "kick" | "ban") => {
@ -109,7 +79,8 @@ const UserModeration = ({ userID, client, member, room }: UserModerationProps) =
const membership = member?.content.membership || "leave"
return <div className="user-moderation">
<h4>Moderation</h4>
<h4>Actions</h4>
{!room || room.meta.current.dm_user_id !== userID ? <StartDMButton userID={userID} client={client} /> : null}
{room && (["knock", "leave"].includes(membership) || !member) && hasPL("invite") && (
<button className="moderation-action positive" onClick={runAction("invite")}>
<InviteIcon />