feat: Add emoji and alot of other fixes
This commit is contained in:
@@ -44,7 +44,11 @@ export class AttachmentManagerService {
|
||||
effect(() => {
|
||||
if (this.database.isReady() && !this.isDatabaseInitialised) {
|
||||
this.isDatabaseInitialised = true;
|
||||
void this.persistence.initFromDatabase();
|
||||
void this.persistence.initFromDatabase().then(() => {
|
||||
if (this.watchedRoomId) {
|
||||
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,12 +60,14 @@ export class AttachmentManagerService {
|
||||
this.watchedRoomId = this.extractWatchedRoomId(event.urlAfterRedirects || event.url);
|
||||
|
||||
if (this.watchedRoomId) {
|
||||
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
|
||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||
}
|
||||
});
|
||||
|
||||
this.webrtc.onPeerConnected.subscribe(() => {
|
||||
if (this.watchedRoomId) {
|
||||
void this.restoreLocalAttachmentsForRoom(this.watchedRoomId);
|
||||
void this.requestAutoDownloadsForRoom(this.watchedRoomId);
|
||||
}
|
||||
});
|
||||
@@ -110,11 +116,11 @@ export class AttachmentManagerService {
|
||||
return this.transfer.getAttachmentMetasForMessages(messageIds);
|
||||
}
|
||||
|
||||
registerSyncedAttachments(
|
||||
async registerSyncedAttachments(
|
||||
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||
messageRoomIds?: Record<string, string>
|
||||
): void {
|
||||
this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
|
||||
): Promise<void> {
|
||||
await this.transfer.registerSyncedAttachments(attachmentMap, messageRoomIds);
|
||||
|
||||
for (const [messageId, attachments] of Object.entries(attachmentMap)) {
|
||||
for (const attachment of attachments) {
|
||||
@@ -123,20 +129,20 @@ export class AttachmentManagerService {
|
||||
}
|
||||
}
|
||||
|
||||
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.requestFromAnyPeer(messageId, attachment);
|
||||
requestFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
|
||||
return this.transfer.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
handleFileNotFound(payload: FileNotFoundPayload): void {
|
||||
this.transfer.handleFileNotFound(payload);
|
||||
}
|
||||
|
||||
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.requestImageFromAnyPeer(messageId, attachment);
|
||||
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
|
||||
return this.transfer.requestImageFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
requestFile(messageId: string, attachment: Attachment): void {
|
||||
this.transfer.requestFile(messageId, attachment);
|
||||
requestFile(messageId: string, attachment: Attachment): Promise<void> {
|
||||
return this.transfer.requestFile(messageId, attachment);
|
||||
}
|
||||
|
||||
async publishAttachments(
|
||||
@@ -180,11 +186,66 @@ export class AttachmentManagerService {
|
||||
await this.transfer.fulfillRequestWithFile(messageId, fileId, targetPeerId, file);
|
||||
}
|
||||
|
||||
private async restoreLocalAttachmentsForRoom(roomId: string): Promise<void> {
|
||||
if (!this.isRoomWatched(roomId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.persistence.whenReady();
|
||||
|
||||
const messageIds = await this.collectMessageIdsForRoom(roomId);
|
||||
let hasChanges = false;
|
||||
|
||||
for (const messageId of messageIds) {
|
||||
for (const attachment of this.runtimeStore.getAttachmentsForMessage(messageId)) {
|
||||
if (await this.persistence.tryRestoreAttachmentFromLocal(attachment)) {
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (hasChanges) {
|
||||
this.runtimeStore.touch();
|
||||
}
|
||||
}
|
||||
|
||||
private async collectMessageIdsForRoom(roomId: string): Promise<string[]> {
|
||||
if (isDirectMessageAttachmentRoomId(roomId)) {
|
||||
const messageIds: string[] = [];
|
||||
|
||||
for (const [messageId] of this.runtimeStore.getAttachmentEntries()) {
|
||||
const attachmentRoomId = await this.persistence.resolveMessageRoomId(messageId);
|
||||
|
||||
if (attachmentRoomId === roomId) {
|
||||
messageIds.push(messageId);
|
||||
}
|
||||
}
|
||||
|
||||
return messageIds;
|
||||
}
|
||||
|
||||
if (!this.database.isReady()) {
|
||||
return Array.from(this.runtimeStore.getAttachmentEntries())
|
||||
.filter(([messageId]) => this.runtimeStore.getMessageRoomId(messageId) === roomId)
|
||||
.map(([messageId]) => messageId);
|
||||
}
|
||||
|
||||
const messages = await this.database.getMessages(roomId, 500, 0);
|
||||
|
||||
for (const message of messages) {
|
||||
this.runtimeStore.rememberMessageRoom(message.id, message.roomId);
|
||||
}
|
||||
|
||||
return messages.map((message) => message.id);
|
||||
}
|
||||
|
||||
private async runAutoDownloadsForRoom(roomId: string): Promise<void> {
|
||||
if (!this.isRoomWatched(roomId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.restoreLocalAttachmentsForRoom(roomId);
|
||||
|
||||
if (isDirectMessageAttachmentRoomId(roomId)) {
|
||||
await this.requestAutoDownloadsForRuntimeRoom(roomId);
|
||||
return;
|
||||
@@ -242,7 +303,7 @@ export class AttachmentManagerService {
|
||||
if (this.transfer.hasPendingRequest(messageId, attachment.id))
|
||||
continue;
|
||||
|
||||
this.transfer.requestFromAnyPeer(messageId, attachment);
|
||||
void this.transfer.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,10 +7,13 @@ import { AttachmentStorageService } from '../../infrastructure/services/attachme
|
||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
||||
import { LEGACY_ATTACHMENTS_STORAGE_KEY } from '../../domain/constants/attachment-transfer.constants';
|
||||
import { mergeAttachmentLocalPaths } from '../../domain/logic/attachment-persistence.rules';
|
||||
import { AttachmentRuntimeStore } from './attachment-runtime.store';
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentPersistenceService {
|
||||
private initPromise: Promise<void> | null = null;
|
||||
|
||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||
private readonly ngrxStore = inject(Store);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
@@ -51,11 +54,26 @@ export class AttachmentPersistenceService {
|
||||
}
|
||||
}
|
||||
|
||||
whenReady(): Promise<void> {
|
||||
if (this.database.isReady()) {
|
||||
return this.initFromDatabase();
|
||||
}
|
||||
|
||||
return this.initPromise ?? Promise.resolve();
|
||||
}
|
||||
|
||||
async persistAttachmentMeta(attachment: Attachment): Promise<void> {
|
||||
if (!this.database.isReady())
|
||||
return;
|
||||
|
||||
try {
|
||||
const storedRecords = await this.database.getAttachmentsForMessage(attachment.messageId);
|
||||
const storedRecord = storedRecords.find((record) => record.id === attachment.id);
|
||||
const localPaths = mergeAttachmentLocalPaths(attachment, storedRecord);
|
||||
|
||||
attachment.filePath = localPaths.filePath ?? undefined;
|
||||
attachment.savedPath = localPaths.savedPath ?? undefined;
|
||||
|
||||
await this.database.saveAttachment({
|
||||
id: attachment.id,
|
||||
messageId: attachment.messageId,
|
||||
@@ -64,8 +82,8 @@ export class AttachmentPersistenceService {
|
||||
mime: attachment.mime,
|
||||
isImage: attachment.isImage,
|
||||
uploaderPeerId: attachment.uploaderPeerId,
|
||||
filePath: attachment.filePath,
|
||||
savedPath: attachment.savedPath
|
||||
filePath: localPaths.filePath ?? undefined,
|
||||
savedPath: localPaths.savedPath ?? undefined
|
||||
});
|
||||
} catch { /* persistence is best-effort */ }
|
||||
}
|
||||
@@ -87,9 +105,73 @@ export class AttachmentPersistenceService {
|
||||
}
|
||||
|
||||
async initFromDatabase(): Promise<void> {
|
||||
await this.loadFromDatabase();
|
||||
await this.migrateFromLocalStorage();
|
||||
await this.tryLoadSavedFiles();
|
||||
if (!this.initPromise) {
|
||||
this.initPromise = this.runInitFromDatabase();
|
||||
}
|
||||
|
||||
return this.initPromise;
|
||||
}
|
||||
|
||||
async tryRestoreAttachmentFromLocal(attachment: Attachment): Promise<boolean> {
|
||||
if (attachment.available) {
|
||||
return true;
|
||||
}
|
||||
|
||||
let diskPath = await this.attachmentStorage.resolveExistingPath(attachment);
|
||||
|
||||
if (!diskPath) {
|
||||
const roomName = await this.resolveStorageContainerName(attachment);
|
||||
|
||||
diskPath = await this.attachmentStorage.resolveCanonicalStoredPath(attachment, roomName);
|
||||
|
||||
if (diskPath) {
|
||||
attachment.savedPath = diskPath;
|
||||
void this.persistAttachmentMeta(attachment);
|
||||
}
|
||||
}
|
||||
|
||||
if (!diskPath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (await this.restoreMediaAttachmentFromFileUrl(attachment, diskPath)) {
|
||||
attachment.requestError = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
const base64 = await this.attachmentStorage.readFile(diskPath);
|
||||
|
||||
if (!base64) {
|
||||
return false;
|
||||
}
|
||||
|
||||
this.restoreAttachmentFromDisk(attachment, base64);
|
||||
attachment.requestError = undefined;
|
||||
return true;
|
||||
}
|
||||
|
||||
async persistUploadCopyFromSourcePath(attachment: Attachment, sourcePath: string): Promise<string | null> {
|
||||
try {
|
||||
const storageContainer = await this.resolveStorageContainerName(attachment);
|
||||
const diskPath = await this.attachmentStorage.createWritableFile(attachment, storageContainer);
|
||||
|
||||
if (!diskPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const copied = await this.attachmentStorage.copyFile(sourcePath, diskPath);
|
||||
|
||||
if (!copied) {
|
||||
await this.attachmentStorage.deleteFile(diskPath);
|
||||
return null;
|
||||
}
|
||||
|
||||
attachment.savedPath = diskPath;
|
||||
await this.persistAttachmentMeta(attachment);
|
||||
return diskPath;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveMessageRoomId(messageId: string): Promise<string | null> {
|
||||
@@ -173,50 +255,20 @@ export class AttachmentPersistenceService {
|
||||
} catch { /* migration is best-effort */ }
|
||||
}
|
||||
|
||||
private async runInitFromDatabase(): Promise<void> {
|
||||
await this.loadFromDatabase();
|
||||
await this.migrateFromLocalStorage();
|
||||
await this.tryLoadSavedFiles();
|
||||
}
|
||||
|
||||
private async tryLoadSavedFiles(): Promise<void> {
|
||||
try {
|
||||
let hasChanges = false;
|
||||
|
||||
for (const [, attachments] of this.runtimeStore.getAttachmentEntries()) {
|
||||
for (const attachment of attachments) {
|
||||
if (attachment.available)
|
||||
continue;
|
||||
|
||||
if (attachment.savedPath) {
|
||||
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.savedPath)) {
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const savedBase64 = await this.attachmentStorage.readFile(attachment.savedPath);
|
||||
|
||||
if (savedBase64) {
|
||||
this.restoreAttachmentFromDisk(attachment, savedBase64);
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (attachment.filePath) {
|
||||
if (await this.restoreMediaAttachmentFromFileUrl(attachment, attachment.filePath)) {
|
||||
hasChanges = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const originalBase64 = await this.attachmentStorage.readFile(attachment.filePath);
|
||||
|
||||
if (originalBase64) {
|
||||
this.restoreAttachmentFromDisk(attachment, originalBase64);
|
||||
hasChanges = true;
|
||||
|
||||
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES && attachment.objectUrl) {
|
||||
const response = await fetch(attachment.objectUrl);
|
||||
|
||||
void this.saveFileToDisk(attachment, await response.blob());
|
||||
}
|
||||
|
||||
continue;
|
||||
}
|
||||
if (await this.tryRestoreAttachmentFromLocal(attachment)) {
|
||||
hasChanges = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
import { Injectable, inject } from '@angular/core';
|
||||
import { take } from 'rxjs';
|
||||
import { Store } from '@ngrx/store';
|
||||
import { recordDebugNetworkFileChunk } from '../../../../infrastructure/realtime/logging/debug-network-metrics';
|
||||
import { RealtimeSessionFacade } from '../../../../core/realtime';
|
||||
import { selectCurrentUserId } from '../../../../store/users/users.selectors';
|
||||
import { AttachmentStorageService } from '../../infrastructure/services/attachment-storage.service';
|
||||
import { MAX_AUTO_SAVE_SIZE_BYTES } from '../../domain/constants/attachment.constants';
|
||||
import { shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
|
||||
import { shouldCopyUploaderMediaToAppData, shouldPersistDownloadedAttachment } from '../../domain/logic/attachment.logic';
|
||||
import type { Attachment, AttachmentMeta } from '../../domain/models/attachment.model';
|
||||
import {
|
||||
ATTACHMENT_TRANSFER_EWMA_CURRENT_WEIGHT,
|
||||
ATTACHMENT_TRANSFER_EWMA_PREVIOUS_WEIGHT,
|
||||
DEFAULT_ATTACHMENT_MIME_TYPE,
|
||||
FILE_NOT_FOUND_REQUEST_ERROR,
|
||||
NO_CONNECTED_PEERS_REQUEST_ERROR
|
||||
NO_CONNECTED_PEERS_REQUEST_ERROR,
|
||||
UPLOADER_LOCAL_FILE_MISSING_ERROR
|
||||
} from '../../domain/constants/attachment-transfer.constants';
|
||||
import {
|
||||
type FileAnnounceEvent,
|
||||
@@ -46,6 +50,7 @@ interface ValidFileChunkPayload {
|
||||
|
||||
@Injectable({ providedIn: 'root' })
|
||||
export class AttachmentTransferService {
|
||||
private readonly ngrxStore = inject(Store);
|
||||
private readonly webrtc = inject(RealtimeSessionFacade);
|
||||
private readonly runtimeStore = inject(AttachmentRuntimeStore);
|
||||
private readonly attachmentStorage = inject(AttachmentStorageService);
|
||||
@@ -79,10 +84,12 @@ export class AttachmentTransferService {
|
||||
return result;
|
||||
}
|
||||
|
||||
registerSyncedAttachments(
|
||||
async registerSyncedAttachments(
|
||||
attachmentMap: Record<string, AttachmentMeta[]>,
|
||||
messageRoomIds?: Record<string, string>
|
||||
): void {
|
||||
): Promise<void> {
|
||||
await this.persistence.whenReady();
|
||||
|
||||
if (messageRoomIds) {
|
||||
for (const [messageId, roomId] of Object.entries(messageRoomIds)) {
|
||||
this.runtimeStore.rememberMessageRoom(messageId, roomId);
|
||||
@@ -119,12 +126,28 @@ export class AttachmentTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
requestFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
async requestFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
|
||||
const clearedRequestError = this.clearAttachmentRequestError(attachment);
|
||||
|
||||
if (!attachment.available) {
|
||||
const restoredLocally = await this.persistence.tryRestoreAttachmentFromLocal(attachment);
|
||||
|
||||
if (restoredLocally) {
|
||||
this.runtimeStore.touch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const connectedPeers = this.webrtc.getConnectedPeers();
|
||||
const currentUserId = await this.resolveCurrentUserId();
|
||||
const isUploader = !!attachment.uploaderPeerId &&
|
||||
!!currentUserId &&
|
||||
attachment.uploaderPeerId === currentUserId;
|
||||
|
||||
if (connectedPeers.length === 0) {
|
||||
attachment.requestError = NO_CONNECTED_PEERS_REQUEST_ERROR;
|
||||
attachment.requestError = isUploader
|
||||
? UPLOADER_LOCAL_FILE_MISSING_ERROR
|
||||
: NO_CONNECTED_PEERS_REQUEST_ERROR;
|
||||
this.runtimeStore.touch();
|
||||
console.warn('[Attachments] No connected peers to request file from');
|
||||
return;
|
||||
@@ -157,12 +180,12 @@ export class AttachmentTransferService {
|
||||
}
|
||||
}
|
||||
|
||||
requestImageFromAnyPeer(messageId: string, attachment: Attachment): void {
|
||||
this.requestFromAnyPeer(messageId, attachment);
|
||||
requestImageFromAnyPeer(messageId: string, attachment: Attachment): Promise<void> {
|
||||
return this.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
requestFile(messageId: string, attachment: Attachment): void {
|
||||
this.requestFromAnyPeer(messageId, attachment);
|
||||
requestFile(messageId: string, attachment: Attachment): Promise<void> {
|
||||
return this.requestFromAnyPeer(messageId, attachment);
|
||||
}
|
||||
|
||||
hasPendingRequest(messageId: string, fileId: string): boolean {
|
||||
@@ -209,6 +232,36 @@ export class AttachmentTransferService {
|
||||
|
||||
if (attachment.size <= MAX_AUTO_SAVE_SIZE_BYTES) {
|
||||
void this.persistence.saveFileToDisk(attachment, file);
|
||||
} else if (shouldCopyUploaderMediaToAppData(
|
||||
attachment,
|
||||
attachment.filePath,
|
||||
this.attachmentStorage.canCopyFiles()
|
||||
)) {
|
||||
const savedPath = await this.persistence.persistUploadCopyFromSourcePath(attachment, attachment.filePath!);
|
||||
|
||||
if (savedPath) {
|
||||
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
|
||||
|
||||
if (fileUrl) {
|
||||
attachment.objectUrl = fileUrl;
|
||||
attachment.available = true;
|
||||
}
|
||||
}
|
||||
} else if (
|
||||
this.isPlayableMedia(attachment) &&
|
||||
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES &&
|
||||
this.attachmentStorage.canWriteFiles()
|
||||
) {
|
||||
const savedPath = await this.persistence.saveFileToDisk(attachment, file);
|
||||
|
||||
if (savedPath) {
|
||||
const fileUrl = await this.attachmentStorage.getFileUrl(savedPath);
|
||||
|
||||
if (fileUrl) {
|
||||
attachment.objectUrl = fileUrl;
|
||||
attachment.available = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fileAnnounceEvent: FileAnnounceEvent = {
|
||||
@@ -471,6 +524,15 @@ export class AttachmentTransferService {
|
||||
);
|
||||
}
|
||||
|
||||
private resolveCurrentUserId(): Promise<string | null> {
|
||||
return new Promise<string | null>((resolve) => {
|
||||
this.ngrxStore
|
||||
.select(selectCurrentUserId)
|
||||
.pipe(take(1))
|
||||
.subscribe((userId) => resolve(userId));
|
||||
});
|
||||
}
|
||||
|
||||
private buildTransferKey(messageId: string, fileId: string, peerId: string): string {
|
||||
return `${messageId}:${fileId}:${peerId}`;
|
||||
}
|
||||
|
||||
@@ -18,3 +18,7 @@ export const NO_CONNECTED_PEERS_REQUEST_ERROR = 'No connected peers are availabl
|
||||
|
||||
/** User-facing error when connected peers cannot provide a requested file. */
|
||||
export const FILE_NOT_FOUND_REQUEST_ERROR = 'The connected peers do not have this file right now.';
|
||||
|
||||
/** User-facing error when the uploader's local copy cannot be restored. */
|
||||
export const UPLOADER_LOCAL_FILE_MISSING_ERROR =
|
||||
'Your original upload could not be found on this device. Re-upload the file to restore playback.';
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { mergeAttachmentLocalPaths } from './attachment-persistence.rules';
|
||||
|
||||
describe('attachment persistence rules', () => {
|
||||
it('keeps incoming local paths when they are set', () => {
|
||||
expect(mergeAttachmentLocalPaths(
|
||||
{ filePath: '/tmp/new.mp4', savedPath: '/data/saved.mp4' },
|
||||
{ filePath: '/tmp/old.mp4', savedPath: '/data/old.mp4' }
|
||||
)).toEqual({
|
||||
filePath: '/tmp/new.mp4',
|
||||
savedPath: '/data/saved.mp4'
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves stored local paths when incoming sync metadata omits them', () => {
|
||||
expect(mergeAttachmentLocalPaths(
|
||||
{},
|
||||
{ filePath: '/home/ludde/video.mp4', savedPath: '/appdata/video.mp4' }
|
||||
)).toEqual({
|
||||
filePath: '/home/ludde/video.mp4',
|
||||
savedPath: '/appdata/video.mp4'
|
||||
});
|
||||
});
|
||||
|
||||
it('does not overwrite a stored path with an explicit null', () => {
|
||||
expect(mergeAttachmentLocalPaths(
|
||||
{ filePath: null, savedPath: null },
|
||||
{ filePath: '/home/ludde/video.mp4', savedPath: '/appdata/video.mp4' }
|
||||
)).toEqual({
|
||||
filePath: '/home/ludde/video.mp4',
|
||||
savedPath: '/appdata/video.mp4'
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
export interface AttachmentLocalPaths {
|
||||
filePath?: string | null;
|
||||
savedPath?: string | null;
|
||||
}
|
||||
|
||||
export function mergeAttachmentLocalPaths(
|
||||
incoming: AttachmentLocalPaths,
|
||||
stored?: AttachmentLocalPaths | null
|
||||
): Required<AttachmentLocalPaths> {
|
||||
const resolvePath = (incomingPath: string | null | undefined, storedPath: string | null | undefined): string | null => {
|
||||
if (typeof incomingPath === 'string' && incomingPath.trim()) {
|
||||
return incomingPath;
|
||||
}
|
||||
|
||||
if (typeof storedPath === 'string' && storedPath.trim()) {
|
||||
return storedPath;
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
return {
|
||||
filePath: resolvePath(incoming.filePath, stored?.filePath),
|
||||
savedPath: resolvePath(incoming.savedPath, stored?.savedPath)
|
||||
};
|
||||
}
|
||||
@@ -1,4 +1,8 @@
|
||||
import { getWatchedAttachmentRoomIdFromUrl, isDirectMessageAttachmentRoomId } from './attachment.logic';
|
||||
import {
|
||||
getWatchedAttachmentRoomIdFromUrl,
|
||||
isDirectMessageAttachmentRoomId,
|
||||
shouldCopyUploaderMediaToAppData
|
||||
} from './attachment.logic';
|
||||
|
||||
describe('attachment logic', () => {
|
||||
it('extracts watched server room ids from room URLs', () => {
|
||||
@@ -21,4 +25,23 @@ describe('attachment logic', () => {
|
||||
expect(isDirectMessageAttachmentRoomId('room-1')).toBe(false);
|
||||
expect(isDirectMessageAttachmentRoomId(null)).toBe(false);
|
||||
});
|
||||
|
||||
it('copies large uploader media into app data when a source path exists', () => {
|
||||
expect(shouldCopyUploaderMediaToAppData({
|
||||
size: 64 * 1024 * 1024,
|
||||
mime: 'video/mp4'
|
||||
}, '/home/ludde/video.mp4', true)).toBe(true);
|
||||
});
|
||||
|
||||
it('skips app-data copy for small uploads and missing source paths', () => {
|
||||
expect(shouldCopyUploaderMediaToAppData({
|
||||
size: 1024,
|
||||
mime: 'video/mp4'
|
||||
}, '/home/ludde/video.mp4', true)).toBe(false);
|
||||
|
||||
expect(shouldCopyUploaderMediaToAppData({
|
||||
size: 64 * 1024 * 1024,
|
||||
mime: 'video/mp4'
|
||||
}, undefined, true)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -22,6 +22,17 @@ export function shouldPersistDownloadedAttachment(attachment: Pick<Attachment, '
|
||||
attachment.mime.startsWith('audio/');
|
||||
}
|
||||
|
||||
export function shouldCopyUploaderMediaToAppData(
|
||||
attachment: Pick<Attachment, 'size' | 'mime'>,
|
||||
sourcePath?: string | null,
|
||||
canCopyFiles = false
|
||||
): boolean {
|
||||
return canCopyFiles &&
|
||||
!!sourcePath &&
|
||||
(attachment.mime.startsWith('video/') || attachment.mime.startsWith('audio/')) &&
|
||||
attachment.size > MAX_AUTO_SAVE_SIZE_BYTES;
|
||||
}
|
||||
|
||||
export function getWatchedAttachmentRoomIdFromUrl(url: string): string | null {
|
||||
const path = url.split(/[?#]/, 1)[0];
|
||||
const directMessageMatch = path.match(DIRECT_MESSAGE_URL_PATTERN);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { annotateLocalFilePath, resolveLocalFilePath } from './local-file-path.rules';
|
||||
|
||||
describe('local file path rules', () => {
|
||||
it('prefers an existing path property on the file', () => {
|
||||
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
|
||||
Object.defineProperty(file, 'path', { value: '/tmp/clip.mp4' });
|
||||
|
||||
expect(resolveLocalFilePath(file)).toBe('/tmp/clip.mp4');
|
||||
});
|
||||
|
||||
it('resolves drag-and-drop files through Electron getPathForFile', () => {
|
||||
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
|
||||
|
||||
expect(resolveLocalFilePath(file, {
|
||||
getPathForFile: () => '/home/ludde/Videos/clip.mp4'
|
||||
})).toBe('/home/ludde/Videos/clip.mp4');
|
||||
});
|
||||
|
||||
it('annotates a file object with the resolved path', () => {
|
||||
const file = new File(['video'], 'clip.mp4', { type: 'video/mp4' });
|
||||
const annotated = annotateLocalFilePath(file, {
|
||||
getPathForFile: () => '/home/ludde/Videos/clip.mp4'
|
||||
});
|
||||
|
||||
expect(resolveLocalFilePath(annotated)).toBe('/home/ludde/Videos/clip.mp4');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,54 @@
|
||||
export type LocalFilePathResolver = (file: File) => string;
|
||||
|
||||
type LocalFileWithPath = File & {
|
||||
path?: string;
|
||||
};
|
||||
|
||||
export function resolveLocalFilePath(
|
||||
file: File,
|
||||
options?: {
|
||||
getPathForFile?: LocalFilePathResolver;
|
||||
}
|
||||
): string | undefined {
|
||||
const existingPath = (file as LocalFileWithPath).path?.trim();
|
||||
|
||||
if (existingPath) {
|
||||
return existingPath;
|
||||
}
|
||||
|
||||
if (!options?.getPathForFile) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
try {
|
||||
const resolvedPath = options.getPathForFile(file).trim();
|
||||
|
||||
return resolvedPath || undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function annotateLocalFilePath(
|
||||
file: File,
|
||||
options?: {
|
||||
getPathForFile?: LocalFilePathResolver;
|
||||
}
|
||||
): File {
|
||||
const resolvedPath = resolveLocalFilePath(file, options);
|
||||
|
||||
if (!resolvedPath) {
|
||||
return file;
|
||||
}
|
||||
|
||||
try {
|
||||
Object.defineProperty(file, 'path', {
|
||||
configurable: true,
|
||||
value: resolvedPath
|
||||
});
|
||||
} catch {
|
||||
(file as LocalFileWithPath).path = resolvedPath;
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './application/facades/attachment.facade';
|
||||
export * from './domain/constants/attachment.constants';
|
||||
export * from './domain/logic/local-file-path.rules';
|
||||
export * from './domain/models/attachment.model';
|
||||
|
||||
@@ -25,10 +25,46 @@ export class AttachmentStorageService {
|
||||
return !!electronApi?.readFileChunk && !!electronApi.getFileSize;
|
||||
}
|
||||
|
||||
canCopyFiles(): boolean {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
return !!electronApi?.copyFile && !!electronApi.ensureDir && !!electronApi.getAppDataPath;
|
||||
}
|
||||
|
||||
async resolveExistingPath(
|
||||
attachment: Pick<Attachment, 'filePath' | 'savedPath'>
|
||||
): Promise<string | null> {
|
||||
return this.findExistingPath([attachment.filePath, attachment.savedPath]);
|
||||
return this.findExistingPath([attachment.savedPath, attachment.filePath]);
|
||||
}
|
||||
|
||||
async resolveCanonicalStoredPath(
|
||||
attachment: Pick<Attachment, 'id' | 'filename' | 'mime'>,
|
||||
roomName: string
|
||||
): Promise<string | null> {
|
||||
const appDataPath = await this.resolveAppDataPath();
|
||||
|
||||
if (!appDataPath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const directoryPath = this.resolveStorageDirectoryPath(appDataPath, roomName, attachment.mime);
|
||||
const diskPath = `${directoryPath}/${resolveAttachmentStoredFilename(attachment.id, attachment.filename)}`;
|
||||
|
||||
return this.findExistingPath([diskPath]);
|
||||
}
|
||||
|
||||
async copyFile(sourceFilePath: string, destinationFilePath: string): Promise<boolean> {
|
||||
const electronApi = this.electronBridge.getApi();
|
||||
|
||||
if (!electronApi?.copyFile || !sourceFilePath || !destinationFilePath) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return await electronApi.copyFile(sourceFilePath, destinationFilePath);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async resolveLegacyImagePath(filename: string, roomName: string): Promise<string | null> {
|
||||
|
||||
Reference in New Issue
Block a user