diff --git a/pkg/hicli/json-commands.go b/pkg/hicli/json-commands.go index ba5b551..d6fdcf6 100644 --- a/pkg/hicli/json-commands.go +++ b/pkg/hicli/json-commands.go @@ -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) } diff --git a/web/src/api/rpc.ts b/web/src/api/rpc.ts index 3cc7236..e93b420 100644 --- a/web/src/api/rpc.ts +++ b/web/src/api/rpc.ts @@ -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 { return this.request("register_push", reg) } + + createRoom(request: ReqCreateRoom): Promise { + return this.request("create_room", request) + } } diff --git a/web/src/api/types/mxtypes.ts b/web/src/api/types/mxtypes.ts index 94059e5..9413e49 100644 --- a/web/src/api/types/mxtypes.ts +++ b/web/src/api/types/mxtypes.ts @@ -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 + }[] + room_version?: string + creation_content?: Record + power_level_content_override?: Record + "fi.mau.room_id"?: RoomID +} + +export interface RespCreateRoom { + room_id: RoomID +} diff --git a/web/src/icons/chat.svg b/web/src/icons/chat.svg new file mode 100644 index 0000000..4d4d233 --- /dev/null +++ b/web/src/icons/chat.svg @@ -0,0 +1 @@ + diff --git a/web/src/ui/rightpanel/StartDMButton.tsx b/web/src/ui/rightpanel/StartDMButton.tsx new file mode 100644 index 0000000..b00a415 --- /dev/null +++ b/web/src/ui/rightpanel/StartDMButton.tsx @@ -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 . +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 +} + +export default StartDMButton diff --git a/web/src/ui/rightpanel/UserIgnoreButton.tsx b/web/src/ui/rightpanel/UserIgnoreButton.tsx new file mode 100644 index 0000000..965fcfb --- /dev/null +++ b/web/src/ui/rightpanel/UserIgnoreButton.tsx @@ -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 . +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 ( + + ) +} + +export default UserIgnoreButton diff --git a/web/src/ui/rightpanel/UserModeration.tsx b/web/src/ui/rightpanel/UserModeration.tsx index d5133a6..c7a7d0a 100644 --- a/web/src/ui/rightpanel/UserModeration.tsx +++ b/web/src/ui/rightpanel/UserModeration.tsx @@ -15,12 +15,13 @@ // along with this program. If not, see . 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 ( - - ) -} - 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
-

Moderation

+

Actions

+ {!room || room.meta.current.dm_user_id !== userID ? : null} {room && (["knock", "leave"].includes(membership) || !member) && hasPL("invite") && (