forked from Mirrors/gomuks
parent
a3873643ec
commit
bf7769ee95
8 changed files with 129 additions and 23 deletions
|
@ -13,6 +13,7 @@
|
||||||
//
|
//
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
|
import type { MouseEvent } from "react"
|
||||||
import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
|
import { CachedEventDispatcher, NonNullCachedEventDispatcher } from "../util/eventdispatcher.ts"
|
||||||
import RPCClient, { SendMessageParams } from "./rpc.ts"
|
import RPCClient, { SendMessageParams } from "./rpc.ts"
|
||||||
import { RoomStateStore, StateStore } from "./statestore"
|
import { RoomStateStore, StateStore } from "./statestore"
|
||||||
|
@ -66,8 +67,20 @@ export default class Client {
|
||||||
}
|
}
|
||||||
console.log("Successfully authenticated, connecting to websocket")
|
console.log("Successfully authenticated, connecting to websocket")
|
||||||
this.rpc.start()
|
this.rpc.start()
|
||||||
window.Notification?.requestPermission()
|
this.requestNotificationPermission()
|
||||||
.then(permission => console.log("Notification permission:", permission))
|
}
|
||||||
|
|
||||||
|
requestNotificationPermission = (evt?: MouseEvent) => {
|
||||||
|
window.Notification?.requestPermission().then(permission => {
|
||||||
|
console.log("Notification permission:", permission)
|
||||||
|
if (evt) {
|
||||||
|
window.alert(`Notification permission: ${permission}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
registerURIHandler = () => {
|
||||||
|
navigator.registerProtocolHandler("matrix", "#/uri/%s")
|
||||||
}
|
}
|
||||||
|
|
||||||
start(): () => void {
|
start(): () => void {
|
||||||
|
|
|
@ -37,15 +37,18 @@ export function useRoomState(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export function useRoomMembers(room?: RoomStateStore): MemDBEvent[] {
|
export function useRoomMembers(room?: RoomStateStore): MemDBEvent[] {
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
room ? room.stateSubs.getSubscriber("m.room.member") : noopSubscribe,
|
room ? room.stateSubs.getSubscriber("m.room.member") : noopSubscribe,
|
||||||
room ? room.getMembers : () => [],
|
room ? room.getMembers : returnEmptyArray,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const noopSubscribe = () => () => {}
|
const noopSubscribe = () => () => {}
|
||||||
const returnNull = () => null
|
const returnNull = () => null
|
||||||
|
const emptyArray: never[] = []
|
||||||
|
const returnEmptyArray = () => emptyArray
|
||||||
|
|
||||||
export function useRoomEvent(room: RoomStateStore, eventID: EventID | null): MemDBEvent | null {
|
export function useRoomEvent(room: RoomStateStore, eventID: EventID | null): MemDBEvent | null {
|
||||||
return useSyncExternalStore(
|
return useSyncExternalStore(
|
||||||
|
|
|
@ -19,6 +19,7 @@ import Client from "@/api/client.ts"
|
||||||
import { RoomStateStore } from "@/api/statestore"
|
import { RoomStateStore } from "@/api/statestore"
|
||||||
import type { RoomID } from "@/api/types"
|
import type { RoomID } from "@/api/types"
|
||||||
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
import { useEventAsState } from "@/util/eventdispatcher.ts"
|
||||||
|
import { parseMatrixURI } from "@/util/validation.ts"
|
||||||
import ClientContext from "./ClientContext.ts"
|
import ClientContext from "./ClientContext.ts"
|
||||||
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
|
import MainScreenContext, { MainScreenContextFields } from "./MainScreenContext.ts"
|
||||||
import StylePreferences from "./StylePreferences.tsx"
|
import StylePreferences from "./StylePreferences.tsx"
|
||||||
|
@ -61,6 +62,9 @@ class ContextFields implements MainScreenContextFields {
|
||||||
}
|
}
|
||||||
|
|
||||||
setRightPanel = (props: RightPanelProps | null, pushState = true) => {
|
setRightPanel = (props: RightPanelProps | null, pushState = true) => {
|
||||||
|
if ((props?.type === "members" || props?.type === "pinned-messages") && !this.client.store.activeRoomID) {
|
||||||
|
props = null
|
||||||
|
}
|
||||||
const isEqual = objectIsEqual(this.currentRightPanel, props)
|
const isEqual = objectIsEqual(this.currentRightPanel, props)
|
||||||
if (isEqual && !pushState) {
|
if (isEqual && !pushState) {
|
||||||
return
|
return
|
||||||
|
@ -145,6 +149,42 @@ class ContextFields implements MainScreenContextFields {
|
||||||
|
|
||||||
const SYNC_ERROR_HIDE_DELAY = 30 * 1000
|
const SYNC_ERROR_HIDE_DELAY = 30 * 1000
|
||||||
|
|
||||||
|
const handleURLHash = (client: Client, context: ContextFields) => {
|
||||||
|
if (!location.hash.startsWith("#/uri/")) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const decodedURI = decodeURIComponent(location.hash.slice("#/uri/".length))
|
||||||
|
const uri = parseMatrixURI(decodedURI)
|
||||||
|
if (!uri) {
|
||||||
|
console.error("Invalid matrix URI", decodedURI)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const newURL = new URL(location.href)
|
||||||
|
newURL.hash = ""
|
||||||
|
if (uri.identifier.startsWith("@")) {
|
||||||
|
const right_panel = {
|
||||||
|
type: "user",
|
||||||
|
userID: uri.identifier,
|
||||||
|
} as RightPanelProps
|
||||||
|
history.replaceState({ right_panel }, "", newURL.toString())
|
||||||
|
context.setRightPanel(right_panel, false)
|
||||||
|
} else if (uri.identifier.startsWith("!")) {
|
||||||
|
history.replaceState({ room_id: uri.identifier }, "", newURL.toString())
|
||||||
|
context.setActiveRoom(uri.identifier, false)
|
||||||
|
} else if (uri.identifier.startsWith("#")) {
|
||||||
|
// TODO loading indicator or something for this?
|
||||||
|
client.rpc.resolveAlias(uri.identifier).then(
|
||||||
|
res => {
|
||||||
|
history.replaceState({ room_id: res.room_id }, "", newURL.toString())
|
||||||
|
context.setActiveRoom(res.room_id, false)
|
||||||
|
},
|
||||||
|
err => window.alert(`Failed to resolve room alias ${uri.identifier}: ${err}`),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
console.error("Invalid matrix URI", uri)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const MainScreen = () => {
|
const MainScreen = () => {
|
||||||
const [activeRoom, directSetActiveRoom] = useState<RoomStateStore | null>(null)
|
const [activeRoom, directSetActiveRoom] = useState<RoomStateStore | null>(null)
|
||||||
const [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(null)
|
const [rightPanel, directSetRightPanel] = useState<RightPanelProps | null>(null)
|
||||||
|
@ -166,6 +206,8 @@ const MainScreen = () => {
|
||||||
context.setRightPanel(evt.state?.right_panel ?? null, false)
|
context.setRightPanel(evt.state?.right_panel ?? null, false)
|
||||||
}
|
}
|
||||||
window.addEventListener("popstate", listener)
|
window.addEventListener("popstate", listener)
|
||||||
|
listener({ state: history.state } as PopStateEvent)
|
||||||
|
handleURLHash(client, context)
|
||||||
return () => window.removeEventListener("popstate", listener)
|
return () => window.removeEventListener("popstate", listener)
|
||||||
}, [context, client])
|
}, [context, client])
|
||||||
useEffect(() => context.keybindings.listen(), [context])
|
useEffect(() => context.keybindings.listen(), [context])
|
||||||
|
@ -228,6 +270,7 @@ const MainScreen = () => {
|
||||||
rightPanelResizeHandle={resizeHandle2}
|
rightPanelResizeHandle={resizeHandle2}
|
||||||
/>
|
/>
|
||||||
: rightPanel && <>
|
: rightPanel && <>
|
||||||
|
<div className="room-view placeholder"/>
|
||||||
{resizeHandle2}
|
{resizeHandle2}
|
||||||
{rightPanel && <RightPanel {...rightPanel}/>}
|
{rightPanel && <RightPanel {...rightPanel}/>}
|
||||||
</>}
|
</>}
|
||||||
|
|
|
@ -59,11 +59,12 @@ function renderRightPanelContent(props: RightPanelProps): JSX.Element | null {
|
||||||
}
|
}
|
||||||
|
|
||||||
const RightPanel = (props: RightPanelProps) => {
|
const RightPanel = (props: RightPanelProps) => {
|
||||||
|
const mainScreen = use(MainScreenContext)
|
||||||
let backButton: JSX.Element | null = null
|
let backButton: JSX.Element | null = null
|
||||||
if (props.type === "user") {
|
if (props.type === "user") {
|
||||||
backButton = <button
|
backButton = <button
|
||||||
data-target-panel="members"
|
data-target-panel="members"
|
||||||
onClick={use(MainScreenContext).clickRightPanelOpener}
|
onClick={mainScreen.clickRightPanelOpener}
|
||||||
><BackIcon/></button>
|
><BackIcon/></button>
|
||||||
}
|
}
|
||||||
return <div className="right-panel">
|
return <div className="right-panel">
|
||||||
|
@ -72,7 +73,7 @@ const RightPanel = (props: RightPanelProps) => {
|
||||||
{backButton}
|
{backButton}
|
||||||
<div className="panel-name">{getTitle(props.type)}</div>
|
<div className="panel-name">{getTitle(props.type)}</div>
|
||||||
</div>
|
</div>
|
||||||
<button onClick={use(MainScreenContext).closeRightPanel}><CloseIcon/></button>
|
<button onClick={mainScreen.closeRightPanel}><CloseIcon/></button>
|
||||||
</div>
|
</div>
|
||||||
<div className={`right-panel-content ${props.type}`}>
|
<div className={`right-panel-content ${props.type}`}>
|
||||||
{renderRightPanelContent(props)}
|
{renderRightPanelContent(props)}
|
||||||
|
|
|
@ -88,13 +88,17 @@ div.settings-view {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
button.logout {
|
> div.misc-buttons > button {
|
||||||
margin-top: 1rem;
|
|
||||||
padding: .5rem 1rem;
|
padding: .5rem 1rem;
|
||||||
|
display: block;
|
||||||
|
|
||||||
&:hover, &:focus {
|
&.logout {
|
||||||
background-color: var(--error-color);
|
margin-top: 2rem;
|
||||||
color: var(--inverted-text-color);
|
|
||||||
|
&:hover, &:focus {
|
||||||
|
background-color: var(--error-color);
|
||||||
|
color: var(--inverted-text-color);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -357,7 +357,13 @@ const SettingsView = ({ room }: SettingsViewProps) => {
|
||||||
</table>
|
</table>
|
||||||
<CustomCSSInput setPref={setPref} room={room} />
|
<CustomCSSInput setPref={setPref} room={room} />
|
||||||
<AppliedSettingsView room={room} />
|
<AppliedSettingsView room={room} />
|
||||||
<button className="logout" onClick={onClickLogout}>Logout</button>
|
<div className="misc-buttons">
|
||||||
|
{window.Notification && <button onClick={client.requestNotificationPermission}>
|
||||||
|
Request notification permission
|
||||||
|
</button>}
|
||||||
|
<button onClick={client.registerURIHandler}>Register <code>matrix:</code> URI handler</button>
|
||||||
|
<button className="logout" onClick={onClickLogout}>Logout</button>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -14,7 +14,7 @@
|
||||||
// You should have received a copy of the GNU Affero General Public License
|
// 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/>.
|
// along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
import { MessageEventContent } from "@/api/types"
|
import { MessageEventContent } from "@/api/types"
|
||||||
import { getDisplayname } from "@/util/validation.ts"
|
import { getDisplayname, parseMatrixURI } from "@/util/validation.ts"
|
||||||
import EventContentProps from "./props.ts"
|
import EventContentProps from "./props.ts"
|
||||||
|
|
||||||
function isImageElement(elem: EventTarget): elem is HTMLImageElement {
|
function isImageElement(elem: EventTarget): elem is HTMLImageElement {
|
||||||
|
@ -26,21 +26,19 @@ function isAnchorElement(elem: EventTarget): elem is HTMLAnchorElement {
|
||||||
}
|
}
|
||||||
|
|
||||||
function onClickMatrixURI(href: string) {
|
function onClickMatrixURI(href: string) {
|
||||||
const url = new URL(href)
|
const uri = parseMatrixURI(href)
|
||||||
const pathParts = url.pathname.split("/")
|
switch (uri?.identifier[0]) {
|
||||||
const decodedPart = decodeURIComponent(pathParts[1])
|
case "@":
|
||||||
switch (pathParts[0]) {
|
|
||||||
case "u":
|
|
||||||
return window.mainScreenContext.setRightPanel({
|
return window.mainScreenContext.setRightPanel({
|
||||||
type: "user",
|
type: "user",
|
||||||
userID: `@${decodedPart}`,
|
userID: uri.identifier,
|
||||||
})
|
})
|
||||||
case "roomid":
|
case "!":
|
||||||
return window.mainScreenContext.setActiveRoom(`!${decodedPart}`)
|
return window.mainScreenContext.setActiveRoom(uri.identifier)
|
||||||
case "r":
|
case "#":
|
||||||
return window.client.rpc.resolveAlias(`#${decodedPart}`).then(
|
return window.client.rpc.resolveAlias(uri.identifier).then(
|
||||||
res => window.mainScreenContext.setActiveRoom(res.room_id),
|
res => window.mainScreenContext.setActiveRoom(res.room_id),
|
||||||
err => window.alert(`Failed to resolve room alias #${decodedPart}: ${err}`),
|
err => window.alert(`Failed to resolve room alias ${uri.identifier}: ${err}`),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -39,6 +39,44 @@ export const isRoomID = (roomID: unknown) => isIdentifier<RoomID>(roomID, "!", t
|
||||||
export const isRoomAlias = (roomAlias: unknown) => isIdentifier<RoomAlias>(roomAlias, "#", true)
|
export const isRoomAlias = (roomAlias: unknown) => isIdentifier<RoomAlias>(roomAlias, "#", true)
|
||||||
export const isMXC = (mxc: unknown): mxc is ContentURI => typeof mxc === "string" && mediaRegex.test(mxc)
|
export const isMXC = (mxc: unknown): mxc is ContentURI => typeof mxc === "string" && mediaRegex.test(mxc)
|
||||||
|
|
||||||
|
export interface ParsedMatrixURI {
|
||||||
|
identifier: UserID | RoomID | RoomAlias
|
||||||
|
eventID?: EventID
|
||||||
|
params: URLSearchParams
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parseMatrixURI(uri: unknown): ParsedMatrixURI | undefined {
|
||||||
|
if (typeof uri !== "string") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let parsed: URL
|
||||||
|
try {
|
||||||
|
parsed = new URL(uri)
|
||||||
|
} catch {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (parsed.protocol !== "matrix:") {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const [type, ident1, subtype, ident2] = parsed.pathname.split("/")
|
||||||
|
const output: Partial<ParsedMatrixURI> = {
|
||||||
|
params: parsed.searchParams,
|
||||||
|
}
|
||||||
|
if (type === "u") {
|
||||||
|
output.identifier = `@${ident1}`
|
||||||
|
} else if (type === "r") {
|
||||||
|
output.identifier = `#${ident1}`
|
||||||
|
} else if (type === "roomid") {
|
||||||
|
output.identifier = `!${ident1}`
|
||||||
|
if (subtype === "e") {
|
||||||
|
output.eventID = `$${ident2}`
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
return output as ParsedMatrixURI
|
||||||
|
}
|
||||||
|
|
||||||
export function getLocalpart(userID: UserID): string {
|
export function getLocalpart(userID: UserID): string {
|
||||||
const idx = userID.indexOf(":")
|
const idx = userID.indexOf(":")
|
||||||
return idx > 0 ? userID.slice(1, idx) : userID.slice(1)
|
return idx > 0 ? userID.slice(1, idx) : userID.slice(1)
|
||||||
|
|
Loading…
Add table
Reference in a new issue