Skip to content

Commit 214cf89

Browse files
committed
Move channel auto-join logic to store
- Save joined channels to local storage between sessions - Request chat history for each channel upon joining / reconnecting client - Optimize message grouping to incrementally update between message updates - Add timestamp popover for full date time
1 parent 585cb22 commit 214cf89

8 files changed

Lines changed: 151 additions & 54 deletions

File tree

src/commands/handlers/channel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ export const join: CommandHandler = {
88
execute({ currentChannel, args, stores }) {
99
if (args.length === 0) {
1010
stores.channel.addSystemMessage(currentChannel, 'Select a channel using /join <channel>');
11+
return;
1112
}
1213

1314
const inputChannel = args[0];

src/commands/handlers/operator.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -129,6 +129,7 @@ export const changenick: TODO = null;
129129

130130
export const execute: CommandHandler = {
131131
name: 'execute',
132+
aliases: ['exec'],
132133
description:
133134
'Allow operator to send an IRC command directly through the websocket. For using commands not yet available in the client.',
134135
usage: '/execute <IRC commands>',

src/components/ChatApp.vue

Lines changed: 1 addition & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -15,29 +15,14 @@
1515
</template>
1616

1717
<script setup lang="ts">
18-
import { AUTO_JOIN_CHANNELS } from '#constants';
19-
import { useChannelStore, useIrcStore, useSettingsStore } from '#stores';
20-
import { ref, watch } from 'vue';
18+
import { useChannelStore, useSettingsStore } from '#stores';
2119
import ChannelMessages from './channel/ChannelMessages.vue';
2220
import DraggableWindow from './layout/DraggableWindow.vue';
2321
import ChatInput from './chat/ChatInput.vue';
2422
// TODO: Re-implement resizing tracking
2523
26-
const irc = useIrcStore();
2724
const channel = useChannelStore();
2825
const settings = useSettingsStore();
29-
const joined = ref(false);
30-
31-
watch([joined, () => irc.connectionStatus], ([hasJoined, connectionStatus]) => {
32-
if (hasJoined || connectionStatus !== 'connected') {
33-
return;
34-
}
35-
36-
for (const channelName of AUTO_JOIN_CHANNELS) {
37-
channel.joinChannel(channelName);
38-
}
39-
channel.goToChannel(AUTO_JOIN_CHANNELS[0]);
40-
});
4126
</script>
4227

4328
<style scoped>

src/components/channel/MessageGroupItem.vue

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,22 @@
2121
v-html="formattedMessage"
2222
/>
2323

24-
<time v-if="message.type !== 'notice'" class="message-timestamp flex-shrink-0"
25-
>[{{ formatTime(message.time) }}]</time
26-
>
24+
<BPopover :delay="{ show: 250, hide: 100 }">
25+
<template #target>
26+
<time v-if="message.type !== 'notice'" class="message-timestamp flex-shrink-0"
27+
>[{{ formatTime(message.time) }}]</time
28+
>
29+
</template>
30+
31+
{{ formateDateTime(message.time) }}
32+
</BPopover>
2733
</div>
2834
</template>
2935

3036
<script setup lang="ts">
3137
import type { Message } from '#models';
32-
import { formatTime, md } from '#utils';
38+
import { formateDateTime, formatTime, md } from '#utils';
39+
import { BPopover } from 'bootstrap-vue-next';
3340
import { computed } from 'vue';
3441
3542
const props = defineProps<{ message: Message }>();
Lines changed: 84 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1,39 +1,99 @@
11
import type { Message } from '#models';
2-
import { computed, toValue, type MaybeRefOrGetter, type DeepReadonly } from 'vue';
2+
import { ref, toValue, watch, type DeepReadonly, type MaybeRefOrGetter } from 'vue';
33

44
export interface MessageGroup extends Pick<Message, 'nick' | 'username' | 'type'> {
55
id: string;
66
messages: Message[];
77
}
88

99
/**
10-
* Group consecutive messages from same user.
10+
* Group consecutive messages from same user incrementally.
1111
* Does not group `notice` messages.
1212
*/
1313
export function useMessageGroups(messages: MaybeRefOrGetter<Message[] | DeepReadonly<Message[]>>) {
14-
const messageGroups = computed(() => {
15-
const groups: MessageGroup[] = [];
16-
for (const message of toValue(messages)) {
17-
const prevGroup = groups.at(-1) ?? null;
18-
19-
if (
20-
!prevGroup ||
21-
prevGroup.type !== message.type ||
22-
message.type === 'notice' ||
23-
prevGroup.username !== message.username
24-
) {
25-
groups.push({
26-
id: `group-${message.username}-${message.time}`,
27-
type: message.type,
28-
messages: [message],
29-
nick: message.nick,
30-
username: message.username,
31-
});
14+
const messageGroups = ref<MessageGroup[]>([]);
15+
16+
watch(
17+
() => toValue(messages),
18+
(allMessages) => {
19+
if (allMessages.length === 0) {
20+
messageGroups.value = [];
21+
return;
22+
}
23+
24+
// Remove messages from start of list
25+
while (messageGroups.value.length > 0) {
26+
const firstGroup = messageGroups.value[0];
27+
const firstMessageInGroup = firstGroup.messages[0];
28+
const firstMessageInSource = allMessages[0];
29+
30+
if (firstMessageInGroup.id === firstMessageInSource.id) {
31+
// Synchronized the start of the messages
32+
break;
33+
}
34+
35+
// Otherwise, remove expired message from group
36+
firstGroup.messages.shift();
37+
if (firstGroup.messages.length === 0) {
38+
// Last message of group was removed, so group must be removed
39+
messageGroups.value.shift();
40+
}
41+
}
42+
43+
// Remove messages from middle of list
44+
const totalInGroups = messageGroups.value.reduce(
45+
(total, group) => total + group.messages.length,
46+
0,
47+
);
48+
if (totalInGroups > allMessages.length) {
49+
const messageIds = new Set(allMessages.map((m) => m.id));
50+
for (let index = messageGroups.value.length - 1; index >= 0; index--) {
51+
const group = messageGroups.value[index];
52+
// Remove messages that are no longer present
53+
group.messages = group.messages.filter((m) => messageIds.has(m.id));
54+
if (group.messages.length === 0) {
55+
// Remove group if there are no more messages
56+
messageGroups.value.splice(index, 1);
57+
}
58+
}
59+
}
60+
61+
// Find starting point for adding new messages
62+
let newMessages: readonly Message[] = [];
63+
const lastSurvivingGroup = messageGroups.value.at(-1);
64+
if (!lastSurvivingGroup) {
65+
newMessages = allMessages;
3266
} else {
33-
prevGroup.messages.push(message);
67+
// Safe to assume that the message exists here since empty groups were removed earlier
68+
const safeLastMessageId = lastSurvivingGroup.messages.at(-1)!.id;
69+
const lastMessagePosition = allMessages.findIndex((m) => m.id === safeLastMessageId);
70+
newMessages =
71+
lastMessagePosition === -1 ? allMessages : allMessages.slice(lastMessagePosition + 1);
72+
}
73+
74+
// Add new messages
75+
for (const message of newMessages) {
76+
const prevGroup = messageGroups.value.at(-1) ?? null;
77+
78+
if (
79+
!prevGroup ||
80+
prevGroup.type !== message.type ||
81+
message.type === 'notice' ||
82+
prevGroup.username !== message.username
83+
) {
84+
messageGroups.value.push({
85+
id: `group-${message.username}-${message.id}`,
86+
type: message.type,
87+
messages: [message],
88+
nick: message.nick,
89+
username: message.username,
90+
});
91+
} else {
92+
prevGroup.messages.push(message);
93+
}
3494
}
35-
}
36-
return groups;
37-
});
95+
},
96+
{ deep: 1, immediate: true },
97+
);
3898
return { messageGroups };
3999
}

src/constants/channels.ts

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,12 @@
1-
export const AUTO_JOIN_CHANNELS = ['#test'];
1+
export const AUTO_JOIN_CHANNELS = new Set<string>(['#test']);
22

3-
export const DEFAULT_CHANNELS = ['#general', '#off-topic', '#help', '#labs', '#test'];
3+
export const DEFAULT_CHANNELS = new Set<string>([
4+
'#general',
5+
'#off-topic',
6+
'#help',
7+
'#labs',
8+
'#test',
9+
]);
410

511
export const CHANNEL_DESCRIPTIONS: Record<string, string> = {
612
'#general': 'General chat',

src/stores/channel.ts

Lines changed: 41 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
1+
import { AUTO_JOIN_CHANNELS } from '#constants';
12
import type { Channel, Message, MessageType } from '#models';
23
import { parseNick, sortedInsert } from '#utils';
3-
import { useWindowFocus } from '@vueuse/core';
4+
import { useLocalStorage, useWindowFocus } from '@vueuse/core';
45
import log from 'loglevel';
56
import { defineStore } from 'pinia';
67
import { computed, reactive, readonly, ref, watch } from 'vue';
@@ -17,6 +18,7 @@ const useChannelStore = defineStore('channel', () => {
1718
const isFocusedWindow = useWindowFocus();
1819
const channelMap = reactive(new Map<string, Channel>());
1920
const channelList = computed(() => Array.from(channelMap.keys()));
21+
const joinedChannels = useLocalStorage<Set<string>>('chat_joinedChannels', AUTO_JOIN_CHANNELS);
2022

2123
/**
2224
* Gets channel from list.
@@ -53,12 +55,21 @@ const useChannelStore = defineStore('channel', () => {
5355
});
5456
const currentMessages = computed(() => currentChannel.value?.messages ?? []);
5557

56-
function joinChannel(channel: string) {
58+
function requestChatHistory(channelOrUsername: string) {
59+
if (!irc.client || !channelOrUsername.startsWith('#')) {
60+
// Skip requests for user channels
61+
return;
62+
}
63+
irc.client.raw(`CHATHISTORY LATEST ${channelOrUsername} * ${MAX_MESSAGES_PER_CHANNEL}`);
64+
}
65+
66+
function joinChannel(channel: string, options: Partial<{ force: boolean }> = { force: false }) {
5767
if (!irc.client) {
5868
return;
5969
}
6070

6171
if (
72+
!options.force &&
6273
channelList.value.some(
6374
(c) => channel.localeCompare(c, undefined, { sensitivity: 'accent' }) === 0,
6475
)
@@ -68,15 +79,32 @@ const useChannelStore = defineStore('channel', () => {
6879
return;
6980
}
7081

82+
joinedChannels.value.add(channel);
7183
const isIRCChannel = channel.startsWith('#');
7284
createOrGetChannel(channel);
7385
if (isIRCChannel) {
7486
const newChannel = irc.client.channel(channel);
7587
newChannel.updateUsers();
88+
requestChatHistory(channel);
7689
}
7790
goToChannel(channel);
7891
}
7992

93+
/**
94+
* Re-JOIN channels on application start or on client reconnection
95+
*/
96+
function rejoinChannels() {
97+
if (joinedChannels.value.size === 0) {
98+
return;
99+
}
100+
101+
const channels = Array.from(joinedChannels.value);
102+
for (const channel of channels) {
103+
joinChannel(channel, { force: true });
104+
}
105+
goToChannel(channels[0]);
106+
}
107+
80108
function leaveChannel(channel: string) {
81109
if (!irc.client) {
82110
return;
@@ -100,6 +128,7 @@ const useChannelStore = defineStore('channel', () => {
100128
}
101129
}
102130

131+
joinedChannels.value.delete(channel);
103132
channelMap.delete(channel);
104133
if (isIRCChannel) {
105134
irc.client.part(channel);
@@ -261,18 +290,16 @@ const useChannelStore = defineStore('channel', () => {
261290
const isMe = username === irc.currentUser.username;
262291
if (isMe) {
263292
// Check if incoming message from self is the one sent recently
264-
const pendingIndex = channel.messages.findLastIndex(
293+
const pendingMessage = channel.messages.findLast(
265294
(m) =>
266295
m.status === 'pending' &&
267296
(m.pendingId === event.tags.label || m.message === event.message),
268297
);
269298

270-
if (pendingIndex !== -1) {
299+
if (pendingMessage) {
271300
// Mark incoming message as successful sent if it was pending
272-
newMessage.status = 'sent';
273-
newMessage.pendingId = channel.messages[pendingIndex].pendingId;
274-
// Remove pending version so that incoming message is inserted correctly
275-
channel.messages.splice(pendingIndex, 1);
301+
Object.assign(pendingMessage, newMessage, { status: 'sent' });
302+
return;
276303
}
277304
}
278305

@@ -308,7 +335,13 @@ const useChannelStore = defineStore('channel', () => {
308335
} else {
309336
markAsRead(channel.name);
310337
}
338+
})
339+
.on('connected', () => {
340+
// After successful client reconnection
341+
rejoinChannels();
311342
});
343+
344+
rejoinChannels();
312345
},
313346
);
314347

src/utils/date.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,7 @@ export function formatDate(date: number | Date): string {
55
export function formatTime(date: number | Date): string {
66
return new Intl.DateTimeFormat('en-US', { timeStyle: 'short' }).format(date);
77
}
8+
9+
export function formateDateTime(date: number | Date): string {
10+
return new Intl.DateTimeFormat('en-US', { dateStyle: 'medium', timeStyle: 'short' }).format(date);
11+
}

0 commit comments

Comments
 (0)