Skip to content

Commit

Permalink
typing indicator
Browse files Browse the repository at this point in the history
  • Loading branch information
Puyodead1 committed Sep 8, 2023
1 parent e111c18 commit 2137454
Show file tree
Hide file tree
Showing 5 changed files with 167 additions and 16 deletions.
9 changes: 9 additions & 0 deletions src/components/messaging/MessageInput.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import { HorizontalDivider } from "../Divider";
import Icon from "../Icon";
import IconButton from "../IconButton";
import AttachmentUploadList from "./AttachmentUploadList";
import TypingStatus from "./TypingStatus";

type CustomElement = { type: "paragraph"; children: CustomText[] };
type CustomText = { text: string; bold?: true };
Expand Down Expand Up @@ -180,6 +181,12 @@ function MessageInput(props: Props) {
const isAstChange = editor.operations.some((op) => "set_selection" !== op.type);
if (isAstChange) {
setContent(serialize(value));

// send typing event
if (!props.channel.isTyping) {
logger.debug("Sending typing event");
props.channel.sendTyping();
}
}
}, []);

Expand Down Expand Up @@ -271,6 +278,8 @@ function MessageInput(props: Props) {
</Slate>
</div>
</div>

<TypingStatus channel={props.channel} />
</InnerContainer>
</Container>
);
Expand Down
87 changes: 87 additions & 0 deletions src/components/messaging/TypingStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { observer } from "mobx-react-lite";
import React from "react";
import { PulseLoader } from "react-spinners";
import styled from "styled-components";
import Channel from "../../stores/objects/Channel";

const Typing = styled.div`
overflow: visible;
position: absolute;
bottom: 1px;
left: 16px;
right: 16px;
height: 24px;
font-size: 14px;
font-weight: var(--font-weight-medium);
resize: none;
display: flex;
align-items: center;
color: var(--text);
`;

const TypingIndicator = styled.div`
display: flex;
align-items: center;
overflow: hidden;
`;

const TypingText = styled.span`
white-space: nowrap;
margin-left: 4px;
font-weight: var(--font-weight-light);
`;

const Bold = styled.b`
font-weight: var(--font-weight-medium);
font-size: 14px;
`;

interface Props {
channel: Channel;
}

function TypingStatus({ channel }: Props) {
const getFormattedString = React.useCallback(() => {
const typingUsers = channel.typingUsers;
const userCount = typingUsers.length;

if (userCount === 0) {
return "";
} else if (userCount === 1) {
return (
<>
<Bold>{typingUsers[0].username}</Bold> is typing...
</>
);
} else if (userCount === 2) {
return typingUsers.map((user) => <Bold>{user.username}</Bold>).join(" and ") + " are typing...";
} else if (userCount === 3) {
return (
typingUsers
.slice(0, 2)
.map((user) => <Bold>${user.username}</Bold>)
.join(", ") +
(
<>
and <Bold>${typingUsers[2].username}</Bold> are typing...
</>
)
);
} else {
return <>Several people are typing...</>;
}
}, [channel]);

if (!channel.typingUsers.length) return null;

return (
<Typing>
<TypingIndicator>
<PulseLoader size={6} color="var(--text)" />
<TypingText>{getFormattedString()}</TypingText>
</TypingIndicator>
</Typing>
);
}

export default observer(TypingStatus);
18 changes: 18 additions & 0 deletions src/stores/GatewayConnectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import {
GatewayReadyDispatchData,
GatewayReceivePayload,
GatewaySendPayload,
GatewayTypingStartDispatchData,
PresenceUpdateStatus,
Snowflake,
} from "@spacebarchat/spacebar-api-types/v9";
Expand Down Expand Up @@ -123,6 +124,8 @@ export default class GatewayConnectionStore {
this.dispatchHandlers.set(GatewayDispatchEvents.MessageDelete, this.onMessageDelete);

this.dispatchHandlers.set(GatewayDispatchEvents.PresenceUpdate, this.onPresenceUpdate);

this.dispatchHandlers.set(GatewayDispatchEvents.TypingStart, this.onTypingStart);
}

private onopen = () => {
Expand Down Expand Up @@ -619,4 +622,19 @@ export default class GatewayConnectionStore {
private onPresenceUpdate = (data: GatewayPresenceUpdateDispatchData) => {
this.app.presences.add(data);
};

private onTypingStart = (data: GatewayTypingStartDispatchData) => {
const guild = this.app.guilds.get(data.guild_id!);
if (!guild) {
this.logger.warn(`[TypingStart] Guild ${data.guild_id} not found for channel ${data.channel_id}`);
return;
}
const channel = guild.channels.get(data.channel_id);
if (!channel) {
this.logger.warn(`[TypingStart] Channel ${data.channel_id} not found`);
return;
}

channel.handleTyping(data);
};
}
43 changes: 42 additions & 1 deletion src/stores/objects/Channel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import type {
APIReadState,
APIUser,
APIWebhook,
GatewayTypingStartDispatchData,
GatewayVoiceState,
RESTGetAPIChannelMessagesQuery,
RESTGetAPIChannelMessagesResult,
Expand All @@ -14,12 +15,13 @@ import type {
Snowflake as SnowflakeType,
} from "@spacebarchat/spacebar-api-types/v9";
import { ChannelType, Routes } from "@spacebarchat/spacebar-api-types/v9";
import { action, computed, makeObservable, observable } from "mobx";
import { ObservableMap, action, computed, makeObservable, observable, runInAction } from "mobx";
import Logger from "../../utils/Logger";
import { APIError } from "../../utils/interfaces/api";
import AppStore from "../AppStore";
import MessageStore from "../MessageStore";
import QueuedMessage from "./QueuedMessage";
import User from "./User";

export default class Channel {
private readonly logger: Logger = new Logger("Channel");
Expand Down Expand Up @@ -54,10 +56,13 @@ export default class Channel {
@observable flags: number;
@observable defaultThreadRateLimitPerUser: number;
@observable channelIcon?: keyof typeof Icons;
@observable typingCache: ObservableMap<SnowflakeType, User>;
@observable isTyping = false;
private hasFetchedMessages = false;

constructor(app: AppStore, channel: APIChannel) {
this.app = app;
this.typingCache = new ObservableMap();

this.id = channel.id;
this.createdAt = new Date(channel.created_at);
Expand Down Expand Up @@ -213,6 +218,37 @@ export default class Channel {
);
}

@action
async sendTyping() {
this.isTyping = true;
await this.app.rest.post<void, void>(Routes.channelTyping(this.id));

// expire after 10 seconds
setTimeout(() => {
runInAction(() => {
this.isTyping = false;
});
}, 10000); // TODO: make this configurable?
}

@action
handleTyping(data: GatewayTypingStartDispatchData) {
// this.typingCache.set(data.user_id, data);
const user = this.app.users.get(data.user_id);
if (!user) {
this.logger.warn(`[handleTyping] Unknown user ${data.user_id}`);
return;
}
this.typingCache.set(data.user_id, user);

// expire after 10 seconds
setTimeout(() => {
runInAction(() => {
this.typingCache.delete(data.user_id);
});
}, 10000); // TODO: make this configurable?
}

canSendMessage(content: string, attachments: File[]) {
if (!attachments.length && (!content || !content.trim() || !content.replace(/\r?\n|\r/g, ""))) {
return false;
Expand All @@ -238,4 +274,9 @@ export default class Channel {
this.type === ChannelType.DM
);
}

@computed
get typingUsers() {
return Array.from(this.typingCache.values()).filter((x) => x.id !== this.app.account!.id);
}
}
26 changes: 11 additions & 15 deletions src/utils/REST.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,22 +117,18 @@ export default class REST {
body: body ? JSON.stringify(body) : undefined,
})
.then(async (res) => {
if (res.ok) {
resolve(await res.json());
} else {
// reject with json if content type is json
if (res.headers.get("content-type")?.includes("application/json")) {
return reject(await res.json());
}

// if theres content, reject with text
if (res.headers.get("content-length") !== "0") {
return reject(await res.text());
}

// reject with status code if theres no content
return reject(res.statusText);
// resolve with json if content type is json
if (res.headers.get("content-type")?.includes("application/json")) {
return resolve(await res.json());
}

// if theres content, resolve with text
if (res.headers.get("content-length") !== "0") {
return resolve((await res.text()) as U);
}

if (res.ok) return resolve(res.status as U);
else return reject(res.statusText);
})
.catch(reject);
});
Expand Down

0 comments on commit 2137454

Please sign in to comment.