Skip to content
This repository was archived by the owner on Feb 11, 2026. It is now read-only.

Commit a2f589f

Browse files
authored
Merge pull request #46 from elecordapp/rpc
Rich Presence
2 parents af2c023 + b987190 commit a2f589f

14 files changed

Lines changed: 684 additions & 2 deletions

File tree

res/css/_components.pcss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,7 @@
8888
@import "./structures/_ToastContainer.pcss";
8989
@import "./structures/_UploadBar.pcss";
9090
@import "./structures/_UserMenu.pcss";
91+
@import "./structures/_UserRPC.pcss";
9192
@import "./structures/_ViewSource.pcss";
9293
@import "./structures/auth/_CompleteSecurity.pcss";
9394
@import "./structures/auth/_ConfirmSessionLockTheftView.pcss";
@@ -295,6 +296,7 @@
295296
@import "./views/rooms/_LinkPreviewGroup.pcss";
296297
@import "./views/rooms/_LinkPreviewWidget.pcss";
297298
@import "./views/rooms/_LiveContentSummary.pcss";
299+
@import "./views/rooms/_RoomRPC.pcss";
298300
@import "./views/rooms/_MemberListHeaderView.pcss";
299301
@import "./views/rooms/_MemberListView.pcss";
300302
@import "./views/rooms/_MemberTileView.pcss";

res/css/structures/_SpacePanel.pcss

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -399,6 +399,15 @@ Please see LICENSE files in the repository root for full details.
399399
display: block;
400400
}
401401
}
402+
403+
/* elecord, rpc */
404+
.mx_UserRPC {
405+
padding-bottom: 12px;
406+
border-bottom: 1px solid $separator;
407+
margin: 12px 14px 4px 18px;
408+
width: min-content;
409+
max-width: 226px;
410+
}
402411
}
403412

404413
.mx_SpacePanel_contextMenu {

res/css/structures/_UserRPC.pcss

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
/*
2+
Copyright (c) 2025 hazzuk.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
.mx_UserRPC {
9+
box-sizing: border-box;
10+
display: flex;
11+
align-items: center;
12+
13+
.mx_UserRPC_activity {
14+
width: 32px;
15+
height: 32px;
16+
17+
display: flex;
18+
justify-content: center;
19+
align-items: center;
20+
21+
background-color: #000;
22+
border-radius: 50%;
23+
24+
/* crop image to circle */
25+
overflow: hidden;
26+
27+
/* img {
28+
width: 75%;
29+
height: 75%;
30+
} */
31+
}
32+
}

res/css/views/rooms/_RoomRPC.pcss

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
/*
2+
Copyright (c) 2025 hazzuk.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
.mx_RoomRPC {
9+
display: contents;
10+
11+
img {
12+
border-radius: 50%;
13+
}
14+
}
15+
16+
.mx_RoomTile_dm
17+
.mx_RoomTile_subtitle {
18+
top: 0px !important;
19+
}

res/css/views/rooms/_RoomTile.pcss

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,8 @@ Please see LICENSE files in the repository root for full details.
6262
line-height: 1.25;
6363
position: relative;
6464
top: -1px;
65+
/* elecord, reduced font size */
66+
font-size: 0.75rem;
6567
}
6668

6769
.mx_RoomTile_title,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
/*
2+
Copyright (c) 2025 hazzuk.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React, { useEffect, useState } from 'react';
9+
10+
import { logger } from "matrix-js-sdk/src/logger";
11+
12+
import { BridgeRPC, Activity } from '../../elecord/rpc/BridgeRPC';
13+
14+
// elecord rpc: user component - display local user rpc activity
15+
16+
// (starts rpc activity sender lifecycle)
17+
18+
const UserRPC: React.FC = () => {
19+
const [activity, setActivity] = useState<Activity | null | undefined>(null);
20+
21+
// start rpc bridge
22+
useEffect(() => {
23+
const bridgeRPC = new BridgeRPC();
24+
25+
const interval = setInterval(() => {
26+
setActivity(bridgeRPC.getActivity());
27+
}, 5000); // update every 5s
28+
29+
return () => {
30+
clearInterval(interval);
31+
};
32+
}, []);
33+
34+
// render user activity
35+
return (
36+
<div className="mx_UserRPC">
37+
<div className="mx_UserRPC_activity">
38+
{activity?.application_id && activity.status ? (
39+
<img
40+
src={`https://dcdn.dstn.to/app-icons/${activity.application_id}?size=32`}
41+
alt={activity.name}
42+
title={activity.name}
43+
referrerPolicy="no-referrer"
44+
loading="lazy"
45+
/>
46+
) : (
47+
<p>-</p>
48+
)}
49+
</div>
50+
</div>
51+
);
52+
};
53+
54+
export default UserRPC;

src/components/views/rooms/RoomTile.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import { shouldShowComponent } from "../../../customisations/helpers/UIComponent
4444
import { UIComponent } from "../../../settings/UIFeature";
4545
import { isKnockDenied } from "../../../utils/membership";
4646
import SettingsStore from "../../../settings/SettingsStore";
47+
import DMRoomMap from "../../../utils/DMRoomMap";
4748

4849
interface Props {
4950
room: Room;
@@ -60,6 +61,8 @@ interface State {
6061
generalMenuPosition: PartialDOMRect | null;
6162
call: Call | null;
6263
messagePreview: MessagePreview | null;
64+
isDirectMessage: boolean;
65+
dmUserID: string;
6366
}
6467

6568
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
@@ -81,13 +84,17 @@ class RoomTile extends React.PureComponent<Props, State> {
8184
public constructor(props: Props) {
8285
super(props);
8386

87+
const dmUserID = DMRoomMap.shared().getUserIdForRoomId(this.props.room.roomId);
88+
8489
this.state = {
8590
selected: SdkContextClass.instance.roomViewStore.getRoomId() === this.props.room.roomId,
8691
notificationsMenuPosition: null,
8792
generalMenuPosition: null,
8893
call: CallStore.instance.getCall(this.props.room.roomId),
8994
// generatePreview() will return nothing if the user has previews disabled
9095
messagePreview: null,
96+
isDirectMessage: !!dmUserID,
97+
dmUserID: dmUserID || "",
9198
};
9299

93100
this.notificationState = RoomNotificationStateStore.instance.getRoomState(this.props.room);
@@ -366,9 +373,10 @@ class RoomTile extends React.PureComponent<Props, State> {
366373
* RoomTile has a subtile if one of the following applies:
367374
* - there is a call
368375
* - message previews are enabled and there is a previewable message
376+
* - the room is a DM
369377
*/
370378
private get shouldRenderSubtitle(): boolean {
371-
return !!this.state.call || (this.props.showMessagePreview && !!this.state.messagePreview);
379+
return !!this.state.call || (this.props.showMessagePreview && !!this.state.messagePreview) || this.state.isDirectMessage;
372380
}
373381

374382
public render(): React.ReactElement {
@@ -380,6 +388,7 @@ class RoomTile extends React.PureComponent<Props, State> {
380388
mx_RoomTile_selected: this.state.selected,
381389
mx_RoomTile_hasMenuOpen: !!(this.state.generalMenuPosition || this.state.notificationsMenuPosition),
382390
mx_RoomTile_minimized: this.props.isMinimized,
391+
mx_RoomTile_dm: this.state.isDirectMessage,
383392
});
384393

385394
let name = this.props.room.name;
@@ -402,6 +411,8 @@ class RoomTile extends React.PureComponent<Props, State> {
402411
messagePreview={this.state.messagePreview}
403412
roomId={this.props.room.roomId}
404413
showMessagePreview={this.props.showMessagePreview}
414+
isDirectMessage={this.state.isDirectMessage}
415+
dmUserID={this.state.dmUserID}
405416
/>
406417
) : null;
407418

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
/*
2+
Copyright (c) 2025 hazzuk.
3+
4+
SPDX-License-Identifier: AGPL-3.0-only
5+
Please see LICENSE files in the repository root for full details.
6+
*/
7+
8+
import React, { FC, useEffect, useState } from 'react';
9+
10+
import { logger } from "matrix-js-sdk/src/logger";
11+
12+
import { MatrixClientPeg } from "../../../MatrixClientPeg";
13+
import { ParseRoomRPC } from "../../../elecord/rpc/ParseRoomRPC";
14+
import { Activity } from '../../../elecord/rpc/BridgeRPC';
15+
16+
// elecord rpc: room tile component - display external user rpc activity on dm rooms
17+
18+
// (starts external user rpc activity lifecycle)
19+
20+
interface Props {
21+
roomId: string;
22+
dmUserID: string;
23+
}
24+
25+
const RoomTileRPC: FC<Props> = ({ roomId, dmUserID }) => {
26+
// state to store activity
27+
const [activity, setActivity] = useState<Activity | null | undefined>(null);
28+
// state to trigger re-renders to update time-ago text
29+
const [now, setNow] = useState(Date.now());
30+
31+
// fetch and subscribe to activity updates
32+
useEffect(() => {
33+
(async () => {
34+
const client = MatrixClientPeg.safeGet();
35+
const parseRoomRPC = new ParseRoomRPC(client, roomId, dmUserID);
36+
37+
// fetch initial activity
38+
logger.info("RoomRPC: 🚩 Fetching initial activity:", dmUserID, roomId);
39+
const initialActivity = await parseRoomRPC.getActivity();
40+
setActivity(initialActivity);
41+
if (initialActivity === null) {
42+
logger.debug("RoomRPC: Initial activity is null:", dmUserID, roomId);
43+
parseRoomRPC.cleanup();
44+
}
45+
46+
// monitor for new state events,
47+
// clean up listener when component unmounts
48+
parseRoomRPC.onActivity(newActivity => {
49+
logger.debug("RoomRPC: 🔦 Room state changed:", dmUserID);
50+
setActivity(newActivity);
51+
});
52+
53+
// periodically re-fetch activity manually
54+
const interval = setInterval(async () => {
55+
logger.debug("RoomRPC: ⚙️ Manually refetching activity:", dmUserID);
56+
const refetchedActivity = await parseRoomRPC.getActivity();
57+
setActivity(refetchedActivity);
58+
}, 360000);
59+
60+
return () => {
61+
parseRoomRPC.cleanup();
62+
clearInterval(interval);
63+
};
64+
})();
65+
}, [roomId, dmUserID]);
66+
67+
// self-adjusting timeout: update more frequently when activity is new,
68+
// switch to updating every hour once it's older than an hour
69+
useEffect(() => {
70+
let timer: ReturnType<typeof setTimeout>;
71+
72+
const tick = async () => {
73+
setNow(Date.now());
74+
75+
// default update frequency: 1m
76+
let nextDelay = 60000;
77+
if (activity?.timestamps?.start && !activity.status) {
78+
const elapsed = Date.now() - activity.timestamps.start;
79+
// check activity older than 1h
80+
if (elapsed >= 3600000) {
81+
// update frequency: 1h
82+
nextDelay = 3600000;
83+
}
84+
}
85+
timer = setTimeout(tick, nextDelay);
86+
};
87+
// start cycle
88+
timer = setTimeout(tick, 60000);
89+
90+
return () => clearTimeout(timer);
91+
}, [activity]);
92+
93+
// format elapsed time since activity start
94+
const formatTimeAgo = (start: number): string => {
95+
const diffMinutes = Math.floor((now - start) / (1000 * 60));
96+
97+
// less than 1 minute
98+
if (diffMinutes < 1) return "1m ago: ";
99+
100+
// less than 1 hour
101+
if (diffMinutes < 60) return `${diffMinutes}m ago: `;
102+
103+
// less than 24 hours
104+
const diffHours = Math.floor(diffMinutes / 60);
105+
if (diffHours < 24) return `${diffHours}h ago: `;
106+
107+
// more than 1 day
108+
const diffDays = Math.floor(diffHours / 24);
109+
return `${diffDays}d ago: `;
110+
};
111+
112+
// when activity inactive, prefix elapsed time
113+
const displayName =
114+
activity && !activity.status && activity.timestamps?.start
115+
? formatTimeAgo(activity.timestamps.start) + activity.name
116+
: activity?.name;
117+
118+
// rpc activity (icon and text)
119+
return (
120+
<div className="mx_RoomRPC">
121+
{activity?.application_id && activity.status ? (
122+
<img
123+
src={`https://dcdn.dstn.to/app-icons/${activity.application_id}?size=16`}
124+
alt={activity.name}
125+
title={activity.name}
126+
referrerPolicy="no-referrer"
127+
loading="lazy"
128+
/>
129+
) : (
130+
null
131+
)}
132+
<span
133+
className="mx_RoomTile_subtitle_text"
134+
title={displayName}
135+
>
136+
{displayName}
137+
</span>
138+
</div>
139+
);
140+
};
141+
142+
export default RoomTileRPC;

src/components/views/rooms/RoomTileSubtitle.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,20 @@ import { type MessagePreview } from "../../../stores/room-list/MessagePreviewSto
1414
import { type Call } from "../../../models/Call";
1515
import { RoomTileCallSummary } from "./RoomTileCallSummary";
1616

17+
import RoomTileRPC from "./RoomTileRPC";
18+
1719
interface Props {
1820
call: Call | null;
1921
messagePreview: MessagePreview | null;
2022
roomId: string;
2123
showMessagePreview: boolean;
24+
isDirectMessage: boolean;
25+
dmUserID: string;
2226
}
2327

2428
const messagePreviewId = (roomId: string): string => `mx_RoomTile_messagePreview_${roomId}`;
2529

26-
export const RoomTileSubtitle: React.FC<Props> = ({ call, messagePreview, roomId, showMessagePreview }) => {
30+
export const RoomTileSubtitle: React.FC<Props> = ({ call, messagePreview, roomId, showMessagePreview, isDirectMessage, dmUserID }) => {
2731
if (call) {
2832
return (
2933
<div className="mx_RoomTile_subtitle">
@@ -47,5 +51,14 @@ export const RoomTileSubtitle: React.FC<Props> = ({ call, messagePreview, roomId
4751
);
4852
}
4953

54+
// elecord, rpc
55+
if (isDirectMessage && dmUserID) {
56+
return (
57+
<div className="mx_RoomTile_subtitle">
58+
<RoomTileRPC roomId={roomId} dmUserID={dmUserID} />
59+
</div>
60+
);
61+
}
62+
5063
return null;
5164
};

0 commit comments

Comments
 (0)