feat: Add chat seperator and restore last viewed chat on restart
This commit is contained in:
@@ -18,7 +18,11 @@ import {
|
|||||||
import { CommonModule } from '@angular/common';
|
import { CommonModule } from '@angular/common';
|
||||||
import { Store } from '@ngrx/store';
|
import { Store } from '@ngrx/store';
|
||||||
|
|
||||||
import { DatabaseService } from './infrastructure/persistence';
|
import {
|
||||||
|
DatabaseService,
|
||||||
|
loadGeneralSettingsFromStorage,
|
||||||
|
loadLastViewedChatFromStorage
|
||||||
|
} from './infrastructure/persistence';
|
||||||
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
import { DesktopAppUpdateService } from './core/services/desktop-app-update.service';
|
||||||
import { ServerDirectoryFacade } from './domains/server-directory';
|
import { ServerDirectoryFacade } from './domains/server-directory';
|
||||||
import { NotificationsFacade } from './domains/notifications';
|
import { NotificationsFacade } from './domains/notifications';
|
||||||
@@ -38,8 +42,7 @@ import { RoomsActions } from './store/rooms/rooms.actions';
|
|||||||
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
import { selectCurrentRoom } from './store/rooms/rooms.selectors';
|
||||||
import {
|
import {
|
||||||
ROOM_URL_PATTERN,
|
ROOM_URL_PATTERN,
|
||||||
STORAGE_KEY_CURRENT_USER_ID,
|
STORAGE_KEY_CURRENT_USER_ID
|
||||||
STORAGE_KEY_LAST_VISITED_ROUTE
|
|
||||||
} from './core/constants';
|
} from './core/constants';
|
||||||
import {
|
import {
|
||||||
ThemeNodeDirective,
|
ThemeNodeDirective,
|
||||||
@@ -222,14 +225,16 @@ export class App implements OnInit, OnDestroy {
|
|||||||
}).catch(() => {});
|
}).catch(() => {});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
const last = localStorage.getItem(STORAGE_KEY_LAST_VISITED_ROUTE);
|
|
||||||
|
|
||||||
if (last && typeof last === 'string') {
|
|
||||||
const current = this.router.url;
|
const current = this.router.url;
|
||||||
|
const generalSettings = loadGeneralSettingsFromStorage();
|
||||||
|
const lastViewedChat = loadLastViewedChatFromStorage(currentUserId);
|
||||||
|
|
||||||
if (current === '/' || current === '/search') {
|
if (
|
||||||
this.router.navigate([last], { replaceUrl: true }).catch(() => {});
|
generalSettings.reopenLastViewedChat
|
||||||
}
|
&& lastViewedChat
|
||||||
|
&& (current === '/' || current === '/search')
|
||||||
|
) {
|
||||||
|
this.router.navigate(['/room', lastViewedChat.roomId], { replaceUrl: true }).catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -237,8 +242,6 @@ export class App implements OnInit, OnDestroy {
|
|||||||
if (evt instanceof NavigationEnd) {
|
if (evt instanceof NavigationEnd) {
|
||||||
const url = evt.urlAfterRedirects || evt.url;
|
const url = evt.urlAfterRedirects || evt.url;
|
||||||
|
|
||||||
localStorage.setItem(STORAGE_KEY_LAST_VISITED_ROUTE, url);
|
|
||||||
|
|
||||||
const roomMatch = url.match(ROOM_URL_PATTERN);
|
const roomMatch = url.match(ROOM_URL_PATTERN);
|
||||||
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
const currentRoomId = roomMatch ? roomMatch[1] : null;
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
|
export const STORAGE_KEY_CURRENT_USER_ID = 'metoyou_currentUserId';
|
||||||
export const STORAGE_KEY_LAST_VISITED_ROUTE = 'metoyou_lastVisitedRoute';
|
export const STORAGE_KEY_GENERAL_SETTINGS = 'metoyou_general_settings';
|
||||||
|
export const STORAGE_KEY_LAST_VIEWED_CHAT = 'metoyou_lastViewedChat';
|
||||||
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
export const STORAGE_KEY_CONNECTION_SETTINGS = 'metoyou_connection_settings';
|
||||||
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
|
export const STORAGE_KEY_NOTIFICATION_SETTINGS = 'metoyou_notification_settings';
|
||||||
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
export const STORAGE_KEY_VOICE_SETTINGS = 'metoyou_voice_settings';
|
||||||
|
|||||||
@@ -37,7 +37,17 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@for (message of messages(); track message.id) {
|
@for (message of messages(); track message.id; let index = $index) {
|
||||||
|
@if (dateSeparatorLabels().get(index); as separatorLabel) {
|
||||||
|
<div class="flex items-center gap-3 py-1">
|
||||||
|
<div class="h-px flex-1 bg-border"></div>
|
||||||
|
<span class="rounded-full border border-border bg-background/90 px-3 py-1 text-xs font-medium text-muted-foreground shadow-sm">
|
||||||
|
{{ separatorLabel }}
|
||||||
|
</span>
|
||||||
|
<div class="h-px flex-1 bg-border"></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<app-chat-message-item
|
<app-chat-message-item
|
||||||
[message]="message"
|
[message]="message"
|
||||||
[repliedMessage]="findRepliedMessage(message.replyToId)"
|
[repliedMessage]="findRepliedMessage(message.replyToId)"
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
signal
|
signal
|
||||||
} from '@angular/core';
|
} from '@angular/core';
|
||||||
import { Attachment } from '../../../../../attachment';
|
import { Attachment } from '../../../../../attachment';
|
||||||
|
import { getMessageTimestamp } from '../../../../domain/message.rules';
|
||||||
import { Message } from '../../../../../../shared-kernel';
|
import { Message } from '../../../../../../shared-kernel';
|
||||||
import {
|
import {
|
||||||
ChatMessageDeleteEvent,
|
ChatMessageDeleteEvent,
|
||||||
@@ -45,6 +46,12 @@ declare global {
|
|||||||
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
||||||
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
@ViewChild('messagesContainer') messagesContainer?: ElementRef<HTMLDivElement>;
|
||||||
|
|
||||||
|
private readonly dateSeparatorFormatter = new Intl.DateTimeFormat('en-GB', {
|
||||||
|
day: 'numeric',
|
||||||
|
month: 'long',
|
||||||
|
year: 'numeric'
|
||||||
|
});
|
||||||
|
|
||||||
readonly allMessages = input.required<Message[]>();
|
readonly allMessages = input.required<Message[]>();
|
||||||
readonly channelMessages = input.required<Message[]>();
|
readonly channelMessages = input.required<Message[]>();
|
||||||
readonly loading = input(false);
|
readonly loading = input(false);
|
||||||
@@ -83,6 +90,23 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
() => this.channelMessages().length > this.displayLimit()
|
() => this.channelMessages().length > this.displayLimit()
|
||||||
);
|
);
|
||||||
|
|
||||||
|
readonly dateSeparatorLabels = computed(() => {
|
||||||
|
const labels = new Map<number, string>();
|
||||||
|
let previousDayKey: string | null = null;
|
||||||
|
|
||||||
|
this.messages().forEach((message, index) => {
|
||||||
|
const timestamp = this.getMessageDateTimestamp(message);
|
||||||
|
const currentDayKey = this.getMessageDayKey(timestamp);
|
||||||
|
|
||||||
|
if (currentDayKey !== previousDayKey) {
|
||||||
|
labels.set(index, this.dateSeparatorFormatter.format(new Date(timestamp)));
|
||||||
|
previousDayKey = currentDayKey;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return labels;
|
||||||
|
});
|
||||||
|
|
||||||
private initialScrollObserver: MutationObserver | null = null;
|
private initialScrollObserver: MutationObserver | null = null;
|
||||||
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
private initialScrollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
private boundOnImageLoad: (() => void) | null = null;
|
private boundOnImageLoad: (() => void) | null = null;
|
||||||
@@ -342,6 +366,16 @@ export class ChatMessageListComponent implements AfterViewChecked, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getMessageDateTimestamp(message: Message): number {
|
||||||
|
return message.timestamp || getMessageTimestamp(message);
|
||||||
|
}
|
||||||
|
|
||||||
|
private getMessageDayKey(timestamp: number): string {
|
||||||
|
const date = new Date(timestamp);
|
||||||
|
|
||||||
|
return `${date.getFullYear()}-${date.getMonth()}-${date.getDate()}`;
|
||||||
|
}
|
||||||
|
|
||||||
private scrollToBottomSmooth(): void {
|
private scrollToBottomSmooth(): void {
|
||||||
const element = this.messagesContainer?.nativeElement;
|
const element = this.messagesContainer?.nativeElement;
|
||||||
|
|
||||||
|
|||||||
@@ -9,6 +9,29 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="space-y-3">
|
<div class="space-y-3">
|
||||||
|
<div class="rounded-lg border border-border bg-secondary/20 p-4">
|
||||||
|
<div class="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p class="text-sm font-medium text-foreground">Reopen last chat on launch</p>
|
||||||
|
<p class="text-xs text-muted-foreground">Open the same server and text channel the next time MetoYou starts.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label class="relative inline-flex cursor-pointer items-center">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
[checked]="reopenLastViewedChat()"
|
||||||
|
(change)="onReopenLastViewedChatChange($event)"
|
||||||
|
id="general-reopen-last-chat-toggle"
|
||||||
|
aria-label="Toggle reopen last chat on launch"
|
||||||
|
class="sr-only peer"
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
class="w-10 h-5 bg-secondary rounded-full peer peer-checked:bg-primary peer-checked:after:translate-x-full after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all"
|
||||||
|
></div>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
|
class="rounded-lg border border-border bg-secondary/20 p-4 transition-opacity"
|
||||||
[class.opacity-60]="!isElectron"
|
[class.opacity-60]="!isElectron"
|
||||||
|
|||||||
@@ -9,6 +9,10 @@ import { NgIcon, provideIcons } from '@ng-icons/core';
|
|||||||
import { lucidePower } from '@ng-icons/lucide';
|
import { lucidePower } from '@ng-icons/lucide';
|
||||||
|
|
||||||
import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron/electron-api.models';
|
import type { DesktopSettingsSnapshot } from '../../../../core/platform/electron/electron-api.models';
|
||||||
|
import {
|
||||||
|
loadGeneralSettingsFromStorage,
|
||||||
|
saveGeneralSettingsToStorage
|
||||||
|
} from '../../../../infrastructure/persistence';
|
||||||
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
import { ElectronBridgeService } from '../../../../core/platform/electron/electron-bridge.service';
|
||||||
import { PlatformService } from '../../../../core/platform';
|
import { PlatformService } from '../../../../core/platform';
|
||||||
|
|
||||||
@@ -28,17 +32,29 @@ export class GeneralSettingsComponent {
|
|||||||
private electronBridge = inject(ElectronBridgeService);
|
private electronBridge = inject(ElectronBridgeService);
|
||||||
|
|
||||||
readonly isElectron = this.platform.isElectron;
|
readonly isElectron = this.platform.isElectron;
|
||||||
|
reopenLastViewedChat = signal(true);
|
||||||
autoStart = signal(false);
|
autoStart = signal(false);
|
||||||
closeToTray = signal(true);
|
closeToTray = signal(true);
|
||||||
savingAutoStart = signal(false);
|
savingAutoStart = signal(false);
|
||||||
savingCloseToTray = signal(false);
|
savingCloseToTray = signal(false);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
this.loadGeneralSettings();
|
||||||
|
|
||||||
if (this.isElectron) {
|
if (this.isElectron) {
|
||||||
void this.loadDesktopSettings();
|
void this.loadDesktopSettings();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onReopenLastViewedChatChange(event: Event): void {
|
||||||
|
const input = event.target as HTMLInputElement;
|
||||||
|
const settings = saveGeneralSettingsToStorage({
|
||||||
|
reopenLastViewedChat: !!input.checked
|
||||||
|
});
|
||||||
|
|
||||||
|
this.reopenLastViewedChat.set(settings.reopenLastViewedChat);
|
||||||
|
}
|
||||||
|
|
||||||
async onAutoStartChange(event: Event): Promise<void> {
|
async onAutoStartChange(event: Event): Promise<void> {
|
||||||
const input = event.target as HTMLInputElement;
|
const input = event.target as HTMLInputElement;
|
||||||
const enabled = !!input.checked;
|
const enabled = !!input.checked;
|
||||||
@@ -99,6 +115,12 @@ export class GeneralSettingsComponent {
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadGeneralSettings(): void {
|
||||||
|
const settings = loadGeneralSettingsFromStorage();
|
||||||
|
|
||||||
|
this.reopenLastViewedChat.set(settings.reopenLastViewedChat);
|
||||||
|
}
|
||||||
|
|
||||||
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
|
private applyDesktopSettings(snapshot: DesktopSettingsSnapshot): void {
|
||||||
this.autoStart.set(snapshot.autoStart);
|
this.autoStart.set(snapshot.autoStart);
|
||||||
this.closeToTray.set(snapshot.closeToTray);
|
this.closeToTray.set(snapshot.closeToTray);
|
||||||
|
|||||||
@@ -6,12 +6,15 @@ Offline-first storage layer that keeps messages, users, rooms, reactions, bans,
|
|||||||
|
|
||||||
```
|
```
|
||||||
persistence/
|
persistence/
|
||||||
├── index.ts Barrel (exports DatabaseService)
|
├── app-resume.storage.ts localStorage helpers for launch settings and last viewed chat
|
||||||
|
├── index.ts Barrel (exports DatabaseService and storage helpers)
|
||||||
├── database.service.ts Platform-agnostic facade
|
├── database.service.ts Platform-agnostic facade
|
||||||
├── browser-database.service.ts IndexedDB backend (web)
|
├── browser-database.service.ts IndexedDB backend (web)
|
||||||
└── electron-database.service.ts IPC/SQLite backend (desktop)
|
└── electron-database.service.ts IPC/SQLite backend (desktop)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
`app-resume.storage.ts` is the one exception to the `DatabaseService` facade. It stores lightweight UI-level launch preferences and the last viewed room/channel snapshot in `localStorage`, which would be unnecessary overhead to route through IndexedDB or SQLite.
|
||||||
|
|
||||||
## Platform routing
|
## Platform routing
|
||||||
|
|
||||||
```mermaid
|
```mermaid
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
STORAGE_KEY_GENERAL_SETTINGS,
|
||||||
|
STORAGE_KEY_LAST_VIEWED_CHAT
|
||||||
|
} from '../../core/constants';
|
||||||
|
|
||||||
|
export interface GeneralSettings {
|
||||||
|
reopenLastViewedChat: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_GENERAL_SETTINGS: GeneralSettings = {
|
||||||
|
reopenLastViewedChat: true
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface LastViewedChatSnapshot {
|
||||||
|
userId: string;
|
||||||
|
roomId: string;
|
||||||
|
channelId: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadGeneralSettingsFromStorage(): GeneralSettings {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY_GENERAL_SETTINGS);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return { ...DEFAULT_GENERAL_SETTINGS };
|
||||||
|
}
|
||||||
|
|
||||||
|
return normaliseGeneralSettings(JSON.parse(raw) as Partial<GeneralSettings>);
|
||||||
|
} catch {
|
||||||
|
return { ...DEFAULT_GENERAL_SETTINGS };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveGeneralSettingsToStorage(patch: Partial<GeneralSettings>): GeneralSettings {
|
||||||
|
const nextSettings = normaliseGeneralSettings({
|
||||||
|
...loadGeneralSettingsFromStorage(),
|
||||||
|
...patch
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_GENERAL_SETTINGS, JSON.stringify(nextSettings));
|
||||||
|
} catch {}
|
||||||
|
|
||||||
|
return nextSettings;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function loadLastViewedChatFromStorage(userId?: string | null): LastViewedChatSnapshot | null {
|
||||||
|
try {
|
||||||
|
const raw = localStorage.getItem(STORAGE_KEY_LAST_VIEWED_CHAT);
|
||||||
|
|
||||||
|
if (!raw) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const snapshot = normaliseLastViewedChatSnapshot(JSON.parse(raw) as Partial<LastViewedChatSnapshot>);
|
||||||
|
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (userId && snapshot.userId !== userId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return snapshot;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function saveLastViewedChatToStorage(snapshot: LastViewedChatSnapshot): void {
|
||||||
|
const normalised = normaliseLastViewedChatSnapshot(snapshot);
|
||||||
|
|
||||||
|
if (!normalised) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY_LAST_VIEWED_CHAT, JSON.stringify(normalised));
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function clearLastViewedChatFromStorage(): void {
|
||||||
|
try {
|
||||||
|
localStorage.removeItem(STORAGE_KEY_LAST_VIEWED_CHAT);
|
||||||
|
} catch {}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseGeneralSettings(raw: Partial<GeneralSettings>): GeneralSettings {
|
||||||
|
return {
|
||||||
|
reopenLastViewedChat:
|
||||||
|
typeof raw.reopenLastViewedChat === 'boolean'
|
||||||
|
? raw.reopenLastViewedChat
|
||||||
|
: DEFAULT_GENERAL_SETTINGS.reopenLastViewedChat
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function normaliseLastViewedChatSnapshot(raw: Partial<LastViewedChatSnapshot>): LastViewedChatSnapshot | null {
|
||||||
|
const userId = typeof raw.userId === 'string' ? raw.userId.trim() : '';
|
||||||
|
const roomId = typeof raw.roomId === 'string' ? raw.roomId.trim() : '';
|
||||||
|
const channelId = typeof raw.channelId === 'string'
|
||||||
|
? raw.channelId.trim() || null
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!userId || !roomId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
userId,
|
||||||
|
roomId,
|
||||||
|
channelId
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
|
export * from './app-resume.storage';
|
||||||
export * from './database.service';
|
export * from './database.service';
|
||||||
|
|||||||
@@ -2,7 +2,10 @@
|
|||||||
/* eslint-disable id-length */
|
/* eslint-disable id-length */
|
||||||
/* eslint-disable @typescript-eslint/no-unused-vars,, complexity */
|
/* eslint-disable @typescript-eslint/no-unused-vars,, complexity */
|
||||||
import { Injectable, inject } from '@angular/core';
|
import { Injectable, inject } from '@angular/core';
|
||||||
import { Router } from '@angular/router';
|
import {
|
||||||
|
NavigationEnd,
|
||||||
|
Router
|
||||||
|
} from '@angular/router';
|
||||||
import {
|
import {
|
||||||
Actions,
|
Actions,
|
||||||
createEffect,
|
createEffect,
|
||||||
@@ -15,7 +18,8 @@ import {
|
|||||||
import {
|
import {
|
||||||
of,
|
of,
|
||||||
from,
|
from,
|
||||||
EMPTY
|
EMPTY,
|
||||||
|
merge
|
||||||
} from 'rxjs';
|
} from 'rxjs';
|
||||||
import {
|
import {
|
||||||
map,
|
map,
|
||||||
@@ -38,7 +42,12 @@ import {
|
|||||||
selectSavedRooms
|
selectSavedRooms
|
||||||
} from './rooms.selectors';
|
} from './rooms.selectors';
|
||||||
import { RealtimeSessionFacade } from '../../core/realtime';
|
import { RealtimeSessionFacade } from '../../core/realtime';
|
||||||
import { DatabaseService } from '../../infrastructure/persistence';
|
import {
|
||||||
|
clearLastViewedChatFromStorage,
|
||||||
|
DatabaseService,
|
||||||
|
loadLastViewedChatFromStorage,
|
||||||
|
saveLastViewedChatToStorage
|
||||||
|
} from '../../infrastructure/persistence';
|
||||||
import {
|
import {
|
||||||
CLIENT_UPDATE_REQUIRED_MESSAGE,
|
CLIENT_UPDATE_REQUIRED_MESSAGE,
|
||||||
type ServerSourceSelector,
|
type ServerSourceSelector,
|
||||||
@@ -137,6 +146,19 @@ function resolveRoomChannels(
|
|||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveTextChannelId(
|
||||||
|
channels: Room['channels'] | undefined,
|
||||||
|
preferredChannelId?: string | null
|
||||||
|
): string | null {
|
||||||
|
const textChannels = (channels ?? []).filter((channel) => channel.type === 'text');
|
||||||
|
|
||||||
|
if (preferredChannelId && textChannels.some((channel) => channel.id === preferredChannelId)) {
|
||||||
|
return preferredChannelId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return textChannels[0]?.id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
interface RoomPresenceSignalingMessage {
|
interface RoomPresenceSignalingMessage {
|
||||||
type: string;
|
type: string;
|
||||||
reason?: string;
|
reason?: string;
|
||||||
@@ -183,6 +205,51 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Opens the routed room after login/refresh once saved rooms are available. */
|
||||||
|
syncViewedRoomToRoute$ = createEffect(() =>
|
||||||
|
merge(
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(
|
||||||
|
RoomsActions.loadRoomsSuccess,
|
||||||
|
UsersActions.loadCurrentUserSuccess,
|
||||||
|
UsersActions.setCurrentUser
|
||||||
|
)
|
||||||
|
),
|
||||||
|
this.router.events.pipe(
|
||||||
|
filter((event): event is NavigationEnd => event instanceof NavigationEnd)
|
||||||
|
)
|
||||||
|
).pipe(
|
||||||
|
withLatestFrom(
|
||||||
|
this.store.select(selectCurrentUser),
|
||||||
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectSavedRooms)
|
||||||
|
),
|
||||||
|
mergeMap(([
|
||||||
|
, currentUser,
|
||||||
|
currentRoom,
|
||||||
|
savedRooms
|
||||||
|
]) => {
|
||||||
|
if (!currentUser) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const roomId = this.extractRoomIdFromUrl(this.router.url);
|
||||||
|
|
||||||
|
if (!roomId || currentRoom?.id === roomId) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const room = savedRooms.find((savedRoom) => savedRoom.id === roomId) ?? null;
|
||||||
|
|
||||||
|
if (!room) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(RoomsActions.viewServer({ room }));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
/** Searches the server directory with debounced input. */
|
/** Searches the server directory with debounced input. */
|
||||||
searchServers$ = createEffect(() =>
|
searchServers$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
@@ -431,6 +498,106 @@ export class RoomsEffects {
|
|||||||
{ dispatch: false }
|
{ dispatch: false }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Remembers the viewed room whenever a room becomes active. */
|
||||||
|
persistLastViewedChatOnRoomActivation$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(RoomsActions.createRoomSuccess, RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
||||||
|
withLatestFrom(this.store.select(selectCurrentUser)),
|
||||||
|
tap(([
|
||||||
|
{ room },
|
||||||
|
currentUser
|
||||||
|
]) => {
|
||||||
|
if (!currentUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persisted = loadLastViewedChatFromStorage(currentUser.id);
|
||||||
|
const channelId = persisted?.roomId === room.id
|
||||||
|
? resolveTextChannelId(room.channels, persisted.channelId)
|
||||||
|
: resolveTextChannelId(room.channels);
|
||||||
|
|
||||||
|
saveLastViewedChatToStorage({
|
||||||
|
userId: currentUser.id,
|
||||||
|
roomId: room.id,
|
||||||
|
channelId
|
||||||
|
});
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ dispatch: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Remembers the currently selected text channel for the active room. */
|
||||||
|
persistLastViewedChatOnChannelSelection$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(RoomsActions.selectChannel),
|
||||||
|
withLatestFrom(this.store.select(selectCurrentRoom), this.store.select(selectCurrentUser)),
|
||||||
|
tap(([
|
||||||
|
{ channelId },
|
||||||
|
currentRoom,
|
||||||
|
currentUser
|
||||||
|
]) => {
|
||||||
|
if (!currentRoom || !currentUser) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedChannelId = resolveTextChannelId(currentRoom.channels, channelId);
|
||||||
|
|
||||||
|
if (!resolvedChannelId || resolvedChannelId !== channelId) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
saveLastViewedChatToStorage({
|
||||||
|
userId: currentUser.id,
|
||||||
|
roomId: currentRoom.id,
|
||||||
|
channelId
|
||||||
|
});
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ dispatch: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
/** Restores the last viewed text channel once the active room's channels are known. */
|
||||||
|
restoreLastViewedTextChannel$ = createEffect(() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(
|
||||||
|
RoomsActions.createRoomSuccess,
|
||||||
|
RoomsActions.joinRoomSuccess,
|
||||||
|
RoomsActions.viewServerSuccess,
|
||||||
|
RoomsActions.updateRoom
|
||||||
|
),
|
||||||
|
withLatestFrom(
|
||||||
|
this.store.select(selectCurrentUser),
|
||||||
|
this.store.select(selectCurrentRoom),
|
||||||
|
this.store.select(selectActiveChannelId)
|
||||||
|
),
|
||||||
|
mergeMap(([
|
||||||
|
, currentUser,
|
||||||
|
currentRoom,
|
||||||
|
activeChannelId
|
||||||
|
]) => {
|
||||||
|
if (!currentUser || !currentRoom) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const persisted = loadLastViewedChatFromStorage(currentUser.id);
|
||||||
|
|
||||||
|
if (!persisted || persisted.roomId !== currentRoom.id) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
const channelId = resolveTextChannelId(currentRoom.channels, persisted.channelId);
|
||||||
|
|
||||||
|
if (!channelId || channelId === activeChannelId) {
|
||||||
|
return EMPTY;
|
||||||
|
}
|
||||||
|
|
||||||
|
return of(RoomsActions.selectChannel({ channelId }));
|
||||||
|
})
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
refreshServerOwnedRoomMetadata$ = createEffect(() =>
|
refreshServerOwnedRoomMetadata$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
ofType(RoomsActions.joinRoomSuccess, RoomsActions.viewServerSuccess),
|
||||||
@@ -648,6 +815,22 @@ export class RoomsEffects {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/** Clears stale resume state when the remembered room is removed locally. */
|
||||||
|
clearLastViewedChatOnRoomRemoval$ = createEffect(
|
||||||
|
() =>
|
||||||
|
this.actions$.pipe(
|
||||||
|
ofType(RoomsActions.deleteRoomSuccess, RoomsActions.forgetRoomSuccess),
|
||||||
|
tap(({ roomId }) => {
|
||||||
|
const persisted = loadLastViewedChatFromStorage();
|
||||||
|
|
||||||
|
if (persisted?.roomId === roomId) {
|
||||||
|
clearLastViewedChatFromStorage();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
),
|
||||||
|
{ dispatch: false }
|
||||||
|
);
|
||||||
|
|
||||||
/** Updates room settings (host/admin-only) and broadcasts changes to all peers. */
|
/** Updates room settings (host/admin-only) and broadcasts changes to all peers. */
|
||||||
updateRoomSettings$ = createEffect(() =>
|
updateRoomSettings$ = createEffect(() =>
|
||||||
this.actions$.pipe(
|
this.actions$.pipe(
|
||||||
|
|||||||
Reference in New Issue
Block a user