diff --git a/api/src/main/java/telegram/files/FileRouteHandler.java b/api/src/main/java/telegram/files/FileRouteHandler.java new file mode 100644 index 0000000..5b8b20d --- /dev/null +++ b/api/src/main/java/telegram/files/FileRouteHandler.java @@ -0,0 +1,201 @@ +package telegram.files; + +import cn.hutool.log.Log; +import cn.hutool.log.LogFactory; +import io.vertx.core.MultiMap; +import io.vertx.core.file.FileProps; +import io.vertx.core.file.FileSystem; +import io.vertx.core.http.HttpHeaders; +import io.vertx.core.http.HttpMethod; +import io.vertx.core.http.HttpServerRequest; +import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.net.impl.URIDecoder; +import io.vertx.ext.web.RoutingContext; + +import java.nio.charset.Charset; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import static io.netty.handler.codec.http.HttpResponseStatus.PARTIAL_CONTENT; +import static io.netty.handler.codec.http.HttpResponseStatus.REQUESTED_RANGE_NOT_SATISFIABLE; + +public class FileRouteHandler { + private static final Log LOG = LogFactory.get(); + + public void handle(RoutingContext context, String path, String mimeType) { + HttpServerRequest request = context.request(); + + if (request.method() != HttpMethod.GET && request.method() != HttpMethod.HEAD) { + if (LOG.isTraceEnabled()) + LOG.trace("Not GET or HEAD so ignoring request"); + context.next(); + } else { + if (!request.isEnded()) { + request.pause(); + } + // decode URL path + String uriDecodedPath = URIDecoder.decodeURIComponent(context.normalizedPath(), false); + // if the normalized path is null it cannot be resolved + if (uriDecodedPath == null) { + LOG.warn("Invalid path: " + context.request().path()); + context.next(); + return; + } + // Access fileSystem once here to be safe + FileSystem fs = context.vertx().fileSystem(); + + sendStatic(context, fs, path, mimeType); + } + } + + private void sendStatic(RoutingContext context, FileSystem fileSystem, String path, String mimeType) { + // verify if the file exists + fileSystem + .exists(path, exists -> { + if (exists.failed()) { + if (!context.request().isEnded()) { + context.request().resume(); + } + context.fail(exists.cause()); + return; + } + + // file does not exist, continue... + if (!exists.result()) { + if (!context.request().isEnded()) { + context.request().resume(); + } + context.next(); + return; + } + + // Need to read the props from the filesystem + fileSystem.props(path, res -> { + if (res.succeeded()) { + FileProps props = res.result(); + if (props == null) { + if (!context.request().isEnded()) { + context.request().resume(); + } + context.next(); + } else if (props.isDirectory()) { + context.next(); + } else { + sendFile(context, path, mimeType, props); + } + } else { + if (!context.request().isEnded()) { + context.request().resume(); + } + context.fail(res.cause()); + } + }); + }); + } + + private static final Pattern RANGE = Pattern.compile("^bytes=(\\d+)-(\\d*)$"); + + private void sendFile(RoutingContext context, String file, String contentType, FileProps fileProps) { + final HttpServerRequest request = context.request(); + final HttpServerResponse response = context.response(); + + Long offset = null; + long end; + MultiMap headers; + + if (response.closed()) + return; + + // check if the client is making a range request + String range = request.getHeader("Range"); + // end byte is length - 1 + end = fileProps.size() - 1; + + if (range != null) { + Matcher m = RANGE.matcher(range); + if (m.matches()) { + try { + String part = m.group(1); + // offset cannot be empty + offset = Long.parseLong(part); + // offset must fall inside the limits of the file + if (offset < 0 || offset >= fileProps.size()) { + throw new IndexOutOfBoundsException(); + } + // length can be empty + part = m.group(2); + if (part != null && !part.isEmpty()) { + // ranges are inclusive + end = Math.min(end, Long.parseLong(part)); + // end offset must not be smaller than start offset + if (end < offset) { + throw new IndexOutOfBoundsException(); + } + } + } catch (NumberFormatException | IndexOutOfBoundsException e) { + context.response().putHeader(HttpHeaders.CONTENT_RANGE, "bytes */" + fileProps.size()); + if (!context.request().isEnded()) { + context.request().resume(); + } + context.fail(REQUESTED_RANGE_NOT_SATISFIABLE.code()); + return; + } + } + } + + // notify client we support range requests + headers = response.headers(); + headers.set(HttpHeaders.ACCEPT_RANGES, "bytes"); + // send the content length even for HEAD requests + headers.set(HttpHeaders.CONTENT_LENGTH, Long.toString(end + 1 - (offset == null ? 0 : offset))); + + if (request.method() == HttpMethod.HEAD) { + response.end(); + } else { + if (offset != null) { + // must return content range + headers.set(HttpHeaders.CONTENT_RANGE, "bytes " + offset + "-" + end + "/" + fileProps.size()); + // return a partial response + response.setStatusCode(PARTIAL_CONTENT.code()); + + final long finalOffset = offset; + final long finalLength = end + 1 - offset; + if (contentType != null) { + if (contentType.startsWith("text")) { + response.putHeader(HttpHeaders.CONTENT_TYPE, contentType + ";charset=" + Charset.defaultCharset().name()); + } else { + response.putHeader(HttpHeaders.CONTENT_TYPE, contentType); + } + } + + response.sendFile(file, finalOffset, finalLength, res2 -> { + if (res2.failed()) { + if (!context.request().isEnded()) { + context.request().resume(); + } + context.fail(res2.cause()); + } + }); + } else { + // guess content type + if (contentType != null) { + if (contentType.startsWith("text")) { + response.putHeader(HttpHeaders.CONTENT_TYPE, contentType + ";charset=" + Charset.defaultCharset().name()); + } else { + response.putHeader(HttpHeaders.CONTENT_TYPE, contentType); + } + } + + response.sendFile(file, res2 -> { + if (res2.failed()) { + if (!context.request().isEnded()) { + context.request().resume(); + } + context.fail(res2.cause()); + } + }); + } + } + } + +} diff --git a/api/src/main/java/telegram/files/HttpVerticle.java b/api/src/main/java/telegram/files/HttpVerticle.java index 694dc30..354a935 100644 --- a/api/src/main/java/telegram/files/HttpVerticle.java +++ b/api/src/main/java/telegram/files/HttpVerticle.java @@ -14,6 +14,7 @@ import io.vertx.core.http.CookieSameSite; import io.vertx.core.http.HttpMethod; import io.vertx.core.http.HttpServerOptions; import io.vertx.core.http.HttpServerResponse; +import io.vertx.core.impl.NoStackTraceException; import io.vertx.core.json.Json; import io.vertx.core.json.JsonArray; import io.vertx.core.json.JsonObject; @@ -51,6 +52,8 @@ public class HttpVerticle extends AbstractVerticle { private final AutoRecordsHolder autoRecordsHolder = new AutoRecordsHolder(); + private final FileRouteHandler fileRouteHandler = new FileRouteHandler(); + private static final String SESSION_COOKIE_NAME = "tf"; @Override @@ -150,13 +153,13 @@ public class HttpVerticle extends AbstractVerticle { router.get("/telegram/:telegramId/ping").handler(this::handleTelegramPing); router.get("/telegram/:telegramId/test-network").handler(this::handleTelegramTestNetwork); - router.get("/file/preview").handler(this::handleFilePreview); - router.post("/file/start-download").handler(this::handleFileStartDownload); - router.post("/file/start-download-multiple").handler(this::handleFileStartDownloadMultiple); - router.post("/file/cancel-download").handler(this::handleFileCancelDownload); - router.post("/file/toggle-pause-download").handler(this::handleFileTogglePauseDownload); - router.post("/file/remove").handler(this::handleFileRemove); - router.post("/file/auto-download").handler(this::handleAutoDownload); + router.get("/:telegramId/file/:uniqueId").handler(this::handleFilePreview); + router.post("/:telegramId/file/start-download").handler(this::handleFileStartDownload); + router.post("/:telegramId/file/start-download-multiple").handler(this::handleFileStartDownloadMultiple); + router.post("/:telegramId/file/cancel-download").handler(this::handleFileCancelDownload); + router.post("/:telegramId/file/toggle-pause-download").handler(this::handleFileTogglePauseDownload); + router.post("/:telegramId/file/remove").handler(this::handleFileRemove); + router.post("/:telegramId/file/auto-download").handler(this::handleAutoDownload); router.route() .failureHandler(ctx -> { @@ -557,41 +560,32 @@ public class HttpVerticle extends AbstractVerticle { } private void handleFilePreview(RoutingContext ctx) { - TelegramVerticle telegramVerticle = getTelegramVerticle(ctx); - if (telegramVerticle == null) { + Optional telegramVerticleOptional = getTelegramVerticle(ctx.pathParam("telegramId")); + if (telegramVerticleOptional.isEmpty()) { + ctx.fail(404); return; } - String chatId = ctx.request().getParam("chatId"); - String messageId = ctx.request().getParam("messageId"); - if (StrUtil.isBlank(chatId) || StrUtil.isBlank(messageId)) { - ctx.fail(400); + TelegramVerticle telegramVerticle = telegramVerticleOptional.get(); + String uniqueId = ctx.pathParam("uniqueId"); + if (StrUtil.isBlank(uniqueId)) { + ctx.fail(404); return; } - telegramVerticle.loadPreview(Convert.toLong(chatId), Convert.toLong(messageId)) - .onSuccess(fileIdOrPath -> { - if (fileIdOrPath == null) { - ctx.end(); - return; + telegramVerticle.loadPreview(uniqueId) + .onSuccess(tuple -> { + String mimeType = tuple.v2; + if (StrUtil.isBlank(mimeType)) { + mimeType = FileUtil.getMimeType(tuple.v1); } - if (fileIdOrPath instanceof Integer fileId) { - ctx.json(JsonObject.of("fileId", fileId)); - return; - } - String path = (String) fileIdOrPath; - ctx.response() - .putHeader("Content-Type", FileUtil.getMimeType(path)) - .end(Buffer.buffer(FileUtil.readBytes(path))); + fileRouteHandler.handle(ctx, tuple.v1, mimeType); }) .onFailure(ctx::fail); } private void handleFileStartDownload(RoutingContext ctx) { - TelegramVerticle telegramVerticle = getTelegramVerticle(ctx); - if (telegramVerticle == null) { - return; - } + TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId")); JsonObject jsonObject = ctx.body().asJsonObject(); Long chatId = jsonObject.getLong("chatId"); @@ -608,10 +602,7 @@ public class HttpVerticle extends AbstractVerticle { } private void handleFileStartDownloadMultiple(RoutingContext ctx) { - TelegramVerticle telegramVerticle = getTelegramVerticle(ctx); - if (telegramVerticle == null) { - return; - } + TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId")); JsonObject jsonObject = ctx.body().asJsonObject(); Long chatId = jsonObject.getLong("chatId"); @@ -638,10 +629,7 @@ public class HttpVerticle extends AbstractVerticle { } private void handleFileCancelDownload(RoutingContext ctx) { - TelegramVerticle telegramVerticle = getTelegramVerticle(ctx); - if (telegramVerticle == null) { - return; - } + TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId")); JsonObject jsonObject = ctx.body().asJsonObject(); Integer fileId = jsonObject.getInteger("fileId"); @@ -656,10 +644,7 @@ public class HttpVerticle extends AbstractVerticle { } private void handleFileTogglePauseDownload(RoutingContext ctx) { - TelegramVerticle telegramVerticle = getTelegramVerticle(ctx); - if (telegramVerticle == null) { - return; - } + TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId")); JsonObject jsonObject = ctx.body().asJsonObject(); Integer fileId = jsonObject.getInteger("fileId"); @@ -675,7 +660,7 @@ public class HttpVerticle extends AbstractVerticle { } private void handleFileRemove(RoutingContext ctx) { - TelegramVerticle telegramVerticle = getTelegramVerticle(ctx); + TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId")); if (telegramVerticle == null) { return; } @@ -693,10 +678,8 @@ public class HttpVerticle extends AbstractVerticle { } private void handleAutoDownload(RoutingContext ctx) { - TelegramVerticle telegramVerticle = getTelegramVerticle(ctx); - if (telegramVerticle == null) { - return; - } + TelegramVerticle telegramVerticle = getTelegramVerticleThrow(ctx.pathParam("telegramId")); + String chatId = ctx.request().getParam("chatId"); if (StrUtil.isBlank(chatId)) { ctx.fail(400); @@ -715,6 +698,14 @@ public class HttpVerticle extends AbstractVerticle { .findFirst(); } + public static TelegramVerticle getTelegramVerticleThrow(String telegramId) { + Object id = NumberUtil.isNumber(telegramId) ? Convert.toLong(telegramId) : telegramId; + return telegramVerticles.stream() + .filter(t -> Objects.equals(t.getId(), id)) + .findFirst() + .orElseThrow(() -> new NoStackTraceException("Telegram account not found!")); + } + public static Optional getTelegramVerticle(long telegramId) { return telegramVerticles.stream() .filter(t -> t.telegramRecord != null && t.telegramRecord.id() == telegramId) diff --git a/api/src/main/java/telegram/files/TdApiHelp.java b/api/src/main/java/telegram/files/TdApiHelp.java index 2b86c99..da2b045 100644 --- a/api/src/main/java/telegram/files/TdApiHelp.java +++ b/api/src/main/java/telegram/files/TdApiHelp.java @@ -9,6 +9,7 @@ import cn.hutool.core.convert.TypeConverter; import cn.hutool.core.util.ClassUtil; import cn.hutool.core.util.ReflectUtil; import io.vertx.core.impl.NoStackTraceException; +import io.vertx.core.json.JsonObject; import org.drinkless.tdlib.TdApi; import org.jooq.lambda.tuple.Tuple; import org.jooq.lambda.tuple.Tuple1; @@ -192,9 +193,7 @@ public class TdApiHelp { case TdApi.MessageDocument.CONSTRUCTOR -> { return Optional.of((T) new DocumentHandler(message)); } - default -> { - return Optional.empty(); - } + default -> throw new NoStackTraceException("Unsupported message type: " + message.content.getConstructor()); } } @@ -223,6 +222,10 @@ public class TdApiHelp { } public abstract TdApi.File getFile(); + + public JsonObject getExtraInfo() { + return JsonObject.of(); + } } public static class PhotoHandler extends FileHandler { @@ -291,6 +294,14 @@ public class TdApiHelp { public TdApi.File getFile() { return content.photo.sizes[content.photo.sizes.length - 1].photo; } + + @Override + public JsonObject getExtraInfo() { + TdApi.PhotoSize photo = content.photo.sizes[content.photo.sizes.length - 1]; + return JsonObject.of("width", photo.width, + "height", photo.height, + "type", photo.type); + } } public static class VideoHandler extends FileHandler { @@ -352,6 +363,15 @@ public class TdApiHelp { public TdApi.File getFile() { return content.video.video; } + + @Override + public JsonObject getExtraInfo() { + TdApi.Video video = content.video; + return JsonObject.of("width", video.width, + "height", video.height, + "duration", video.duration, + "mimeType", video.mimeType); + } } public static class AudioHandler extends FileHandler { diff --git a/api/src/main/java/telegram/files/TelegramVerticle.java b/api/src/main/java/telegram/files/TelegramVerticle.java index c74bf1b..bb6f3e8 100644 --- a/api/src/main/java/telegram/files/TelegramVerticle.java +++ b/api/src/main/java/telegram/files/TelegramVerticle.java @@ -185,17 +185,10 @@ public class TelegramVerticle extends AbstractVerticle { Map messageMap = Arrays.stream(m.messages) .filter(Objects::nonNull) .collect(Collectors.toMap(message -> message.id, Function.identity())); - List fileRecords = r.v1.stream() - .map(fileRecord -> { - TdApi.Message message = messageMap.get(fileRecord.messageId()); - FileRecord source = TdApiHelp.getFileHandler(message) - .map(fileHandler -> fileHandler.convertFileRecord(telegramRecord.id())) - .orElse(null); - if (source != null) { - fileRecord = fileRecord.withSourceField(source.id(), source.downloadedSize()); - } - return fileRecord; - }) + List fileRecords = r.v1.stream() + .map(fileRecord -> + this.withSource(fileRecord, messageMap.get(fileRecord.messageId()))) + .filter(Objects::nonNull) .toList(); return Tuple.tuple(fileRecords, r.v2, r.v3); }); @@ -280,66 +273,18 @@ public class TelegramVerticle extends AbstractVerticle { }); } - public Future loadPreview(long chatId, long messageId) { - return DataVerticle.settingRepository - .getByKey(SettingKey.needToLoadImages) - .compose(needToLoadImages -> { - if (!(boolean) needToLoadImages) { - return Future.failedFuture("Need to load images is disabled"); + public Future> loadPreview(String uniqueId) { + return DataVerticle.fileRepository + .getByUniqueId(uniqueId) + .compose(fileRecord -> { + if (fileRecord == null || !fileRecord.isDownloadStatus(FileRecord.DownloadStatus.completed) + || !FileUtil.exist(fileRecord.localPath())) { + return Future.failedFuture("File not found or not downloaded"); } - return client.execute(new TdApi.GetMessage(chatId, messageId)); - }) - .compose(message -> { - TdApiHelp.FileHandler fileHandler = TdApiHelp.getFileHandler(message) - .orElseThrow(() -> new NoStackTraceException("not support message type")); - - return DataVerticle.settingRepository.getByKey(SettingKey.imageLoadSize) - .map(size -> Tuple.tuple(message, fileHandler.getPreviewFileId(Tuple.tuple(size)))); - }) - .compose(tuple -> { - TdApi.Message message = tuple.v1; - TdApi.File file = tuple.v2; - if (file.local != null - && file.local.isDownloadingCompleted - && FileUtil.exist(file.local.path)) { - return Future.succeededFuture(file.local.path); - } - - return savePreviewFile(message, file) - .compose(r -> { - TdApi.DownloadFile downloadFile = new TdApi.DownloadFile(); - downloadFile.fileId = file.id; - downloadFile.priority = 32; - downloadFile.synchronous = true; - - return client.execute(downloadFile) - .map(file.id); - }); + return Future.succeededFuture(Tuple.tuple(fileRecord.localPath(), fileRecord.mimeType())); }); } - private Future savePreviewFile(TdApi.Message message, TdApi.File file) { - return Future.future(promise -> { - if (TdApiHelp.getFileId(message) != file.id) { - promise.complete(); - } else { - DataVerticle.fileRepository.getByUniqueId(file.remote.uniqueId) - .onSuccess(fileRecord -> { - if (fileRecord != null) { - promise.complete(); - } else { - DataVerticle.fileRepository.create(TdApiHelp.getFileHandler(message) - .orElseThrow() - .convertFileRecord(telegramRecord.id())) - .onSuccess(r -> promise.complete()) - .onFailure(promise::fail); - } - }) - .onFailure(promise::fail); - } - }); - } - public Future startDownload(Long chatId, Long messageId, Integer fileId) { return Future.all( client.execute(new TdApi.GetFile(fileId)), @@ -898,24 +843,9 @@ public class TelegramVerticle extends AbstractVerticle { List fileObjects = messages.stream() .filter(message -> TdApiHelp.FILE_CONTENT_CONSTRUCTORS.contains(message.content.getConstructor())) .map(message -> { - FileRecord source = TdApiHelp.getFileHandler(message) - .map(fileHandler -> fileHandler.convertFileRecord(telegramRecord.id())) - .orElse(null); - if (source == null) { - return null; - } - FileRecord fileRecord = fileRecords.get(TdApiHelp.getFileUniqueId(message)); - if (fileRecord == null) { - fileRecord = source; - } else { - fileRecord = fileRecord.withSourceField(source.id(), source.downloadedSize()); - } - //TODO Processing of the same file under different accounts - JsonObject fileObject = JsonObject.mapFrom(fileRecord); - fileObject.put("formatDate", DateUtil.date(fileObject.getLong("date") * 1000).toString()); - return fileObject; + return this.withSource(fileRecords.get(TdApiHelp.getFileUniqueId(message)), message); }) .filter(Objects::nonNull) .toList(); @@ -927,6 +857,26 @@ public class TelegramVerticle extends AbstractVerticle { }); } + private JsonObject withSource(FileRecord fileRecord, TdApi.Message message) { + TdApiHelp.FileHandler fileHandler = TdApiHelp.getFileHandler(message) + .orElse(null); + if (fileHandler == null) { + return null; + } + + FileRecord source = fileHandler.convertFileRecord(telegramRecord.id()); + if (fileRecord == null) { + fileRecord = source; + } else { + fileRecord = fileRecord.withSourceField(source.id(), source.downloadedSize()); + } + + JsonObject fileObject = JsonObject.mapFrom(fileRecord); + fileObject.put("formatDate", DateUtil.date(fileObject.getLong("date") * 1000).toString()); + fileObject.put("extra", fileHandler.getExtraInfo()); + return fileObject; + } + private List convertRangedSpeedStats(List statisticRecords, int timeRange) { TreeMap> groupedSpeedStats = new TreeMap<>(Comparator.comparing( switch (timeRange) { diff --git a/api/src/main/java/telegram/files/repository/SettingKey.java b/api/src/main/java/telegram/files/repository/SettingKey.java index 7f1c50d..7adf30b 100644 --- a/api/src/main/java/telegram/files/repository/SettingKey.java +++ b/api/src/main/java/telegram/files/repository/SettingKey.java @@ -9,7 +9,6 @@ import java.util.function.Function; public enum SettingKey { version(Version::new), uniqueOnly(Convert::toBool, false), - needToLoadImages(Convert::toBool, false), imageLoadSize, alwaysHide(Convert::toBool, false), showSensitiveContent(Convert::toBool, false), diff --git a/web/package-lock.json b/web/package-lock.json index 75a34cf..7c862db 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -1,12 +1,12 @@ { "name": "telegram-files-web", - "version": "0.1.12", + "version": "0.1.13", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "telegram-files-web", - "version": "0.1.12", + "version": "0.1.13", "dependencies": { "@dnd-kit/core": "^6.2.0", "@dnd-kit/modifiers": "^8.0.0", @@ -23,6 +23,7 @@ "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.1", @@ -38,6 +39,7 @@ "framer-motion": "^11.15.0", "geist": "^1.3.0", "input-otp": "^1.4.1", + "lodash": "^4.17.21", "lottie-react": "^2.4.0", "lucide-react": "^0.462.0", "motion": "^11.15.0", @@ -60,6 +62,7 @@ }, "devDependencies": { "@types/eslint": "^8.56.10", + "@types/lodash": "^4.17.15", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", @@ -2310,6 +2313,127 @@ } } }, + "node_modules/@radix-ui/react-slider": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz", + "integrity": "sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slider/node_modules/@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, "node_modules/@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", @@ -2897,6 +3021,13 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "node_modules/@types/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "20.17.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz", @@ -6169,7 +6300,8 @@ "node_modules/lodash": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz", - "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==" + "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", + "license": "MIT" }, "node_modules/lodash.merge": { "version": "4.6.2", @@ -9641,6 +9773,64 @@ } } }, + "@radix-ui/react-slider": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slider/-/react-slider-1.2.3.tgz", + "integrity": "sha512-nNrLAWLjGESnhqBqcCNW4w2nn7LxudyMzeB6VgdyAnFLC6kfQgnAjSL2v6UkQTnDctJBlxrmxfplWS4iYjdUTw==", + "requires": { + "@radix-ui/number": "1.1.0", + "@radix-ui/primitive": "1.1.1", + "@radix-ui/react-collection": "1.1.2", + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-direction": "1.1.0", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-use-controllable-state": "1.1.0", + "@radix-ui/react-use-layout-effect": "1.1.0", + "@radix-ui/react-use-previous": "1.1.0", + "@radix-ui/react-use-size": "1.1.0" + }, + "dependencies": { + "@radix-ui/primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz", + "integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==" + }, + "@radix-ui/react-collection": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz", + "integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.1", + "@radix-ui/react-context": "1.1.1", + "@radix-ui/react-primitive": "2.0.2", + "@radix-ui/react-slot": "1.1.2" + } + }, + "@radix-ui/react-compose-refs": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz", + "integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==", + "requires": {} + }, + "@radix-ui/react-primitive": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz", + "integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==", + "requires": { + "@radix-ui/react-slot": "1.1.2" + } + }, + "@radix-ui/react-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz", + "integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==", + "requires": { + "@radix-ui/react-compose-refs": "1.1.1" + } + } + } + }, "@radix-ui/react-slot": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.0.tgz", @@ -9981,6 +10171,12 @@ "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", "dev": true }, + "@types/lodash": { + "version": "4.17.15", + "resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.15.tgz", + "integrity": "sha512-w/P33JFeySuhN6JLkysYUK2gEmy9kHHFN7E8ro0tkfmlDOgxBDzWEZ/J8cWA+fHqFevpswDTFZnDx+R9lbL6xw==", + "dev": true + }, "@types/node": { "version": "20.17.9", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.17.9.tgz", diff --git a/web/package.json b/web/package.json index 73b9b51..910d4ff 100644 --- a/web/package.json +++ b/web/package.json @@ -31,6 +31,7 @@ "@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-select": "^2.1.2", "@radix-ui/react-separator": "^1.1.1", + "@radix-ui/react-slider": "^1.2.3", "@radix-ui/react-slot": "^1.1.0", "@radix-ui/react-switch": "^1.1.2", "@radix-ui/react-tabs": "^1.1.1", @@ -46,6 +47,7 @@ "framer-motion": "^11.15.0", "geist": "^1.3.0", "input-otp": "^1.4.1", + "lodash": "^4.17.21", "lottie-react": "^2.4.0", "lucide-react": "^0.462.0", "motion": "^11.15.0", @@ -68,6 +70,7 @@ }, "devDependencies": { "@types/eslint": "^8.56.10", + "@types/lodash": "^4.17.15", "@types/node": "^20.14.10", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", diff --git a/web/src/app/account/[accountId]/[chatId]/page.tsx b/web/src/app/account/[accountId]/[chatId]/page.tsx index cb4f13a..87ba249 100644 --- a/web/src/app/account/[accountId]/[chatId]/page.tsx +++ b/web/src/app/account/[accountId]/[chatId]/page.tsx @@ -1,5 +1,5 @@ import { Header } from "@/components/header"; -import { FileList } from "@/components/file-list"; +import Files from "@/components/files"; export default async function ChatFilesPage({ params, @@ -11,7 +11,7 @@ export default async function ChatFilesPage({ return (
- +
); } diff --git a/web/src/components/auto-download-dialog.tsx b/web/src/components/auto-download-dialog.tsx index f668d08..33a6d88 100644 --- a/web/src/components/auto-download-dialog.tsx +++ b/web/src/components/auto-download-dialog.tsx @@ -62,7 +62,7 @@ export default function AutoDownloadDialog() { const { trigger: triggerAuto, isMutating: isAutoMutating } = useSWRMutation( !accountId || !chat ? undefined - : `/file/auto-download?telegramId=${accountId}&chatId=${chat?.id}`, + : `/${accountId}/file/auto-download?telegramId=${accountId}&chatId=${chat?.id}`, (key, { arg }: { arg: { rule: AutoDownloadRule } }) => { return POST(key, arg); }, diff --git a/web/src/components/file-avatar.tsx b/web/src/components/file-avatar.tsx new file mode 100644 index 0000000..515dc43 --- /dev/null +++ b/web/src/components/file-avatar.tsx @@ -0,0 +1,52 @@ +import type { TelegramFile } from "@/lib/types"; +import { FileAudio2Icon, FileIcon, ImageIcon, VideoIcon } from "lucide-react"; +import SpoiledWrapper from "@/components/spoiled-wrapper"; +import React from "react"; +import Image from "next/image"; +import { cn } from "@/lib/utils"; + +export default function FileAvatar({ + file, + className, +}: { + file: TelegramFile; + className?: string; +}) { + const getIcon = () => { + switch (file.type) { + case "photo": + return ; + case "video": + return ; + case "audio": + return ; + default: + return ; + } + }; + + return ( + <> + {file.thumbnail ? ( + + {file.fileName + + ) : ( +
+ {getIcon()} +
+ )} + + ); +} diff --git a/web/src/components/file-card.tsx b/web/src/components/file-card.tsx deleted file mode 100644 index e3316db..0000000 --- a/web/src/components/file-card.tsx +++ /dev/null @@ -1,260 +0,0 @@ -"use client"; - -import { Card, CardContent } from "@/components/ui/card"; -import { type TelegramFile } from "@/lib/types"; -import { - Calendar, - Clock10, - ClockArrowDown, - Download, - FileAudio2Icon, - FileIcon, - HardDrive, - ImageIcon, - Type, - VideoIcon, -} from "lucide-react"; -import SpoiledWrapper from "@/components/spoiled-wrapper"; -import Image from "next/image"; -import prettyBytes from "pretty-bytes"; -import FileProgress from "@/components/file-progress"; -import FileControl, { MobileFileControl } from "@/components/file-control"; -import { - Drawer, - DrawerContent, - DrawerDescription, - DrawerFooter, - DrawerHeader, - DrawerTitle, - DrawerTrigger, -} from "./ui/drawer"; -import PhotoPreview from "@/components/photo-preview"; -import React from "react"; -import { Separator } from "./ui/separator"; -import { Badge } from "./ui/badge"; -import { formatDistanceToNow } from "date-fns"; -import FileStatus from "@/components/file-status"; -import { cn } from "@/lib/utils"; -import { useFileSpeed } from "@/hooks/use-file-speed"; - -interface FileCardProps { - file: TelegramFile; -} - -export function FileCard({ file }: FileCardProps) { - const { downloadProgress, downloadSpeed } = useFileSpeed(file.id); - return ( - - - -
- -
-

{file.fileName}

-
- - {prettyBytes(file.size)} • {file.type} - - -
-
- -
-
- -
-
-
-
-
-
- ); -} - -function FileAvatar({ - file, - loadPreview, -}: { - file: TelegramFile; - loadPreview?: boolean; -}) { - const getIcon = () => { - switch (file.type) { - case "photo": - return ; - case "video": - return ; - case "audio": - return ; - default: - return ; - } - }; - - return ( - <> - {file.type === "photo" || file.type === "video" ? ( - file.thumbnail ? ( - - {loadPreview ? ( - - ) : ( - {file.fileName - )} - - ) : ( -
- {getIcon()} -
- ) - ) : ( -
- {getIcon()} -
- )} - - ); -} - -function FileDrawer({ - file, - downloadProgress, - children, -}: { - file: TelegramFile; - downloadProgress: number; - children: React.ReactNode; -}) { - return ( - - {children} - - - - - {file.fileName} - -
- -
- {file.caption && ( - - "), - }} - > - - )} - -
- -
- -
- -
-
-
-
- - Type -
- - {file.type} - -
- -
-
- - Size -
- - {prettyBytes(file.size)} - -
- -
-
- - Received At -
- - {formatDistanceToNow(new Date(file.date * 1000), { - addSuffix: true, - })} - -
- -
-
- - Downloaded Size -
- - {prettyBytes(file.downloadedSize)} - -
- - {file.downloadStatus !== "idle" && file.startDate !== 0 && ( -
-
- - Download At -
- - {formatDistanceToNow(new Date(file.startDate), { - addSuffix: true, - })} - -
- )} - - {file.completionDate && ( -
-
- - Completion At -
- - {formatDistanceToNow(new Date(file.completionDate), { - addSuffix: true, - })} - -
- )} -
-
- - - - -
-
- ); -} diff --git a/web/src/components/file-control.tsx b/web/src/components/file-control.tsx index 3352b06..959e957 100644 --- a/web/src/components/file-control.tsx +++ b/web/src/components/file-control.tsx @@ -1,7 +1,14 @@ import { type DownloadStatus, type TelegramFile } from "@/lib/types"; import { useFileControl } from "@/hooks/use-file-control"; import { Button } from "@/components/ui/button"; -import {ArrowDown, FileX, Loader2, Pause, SquareX, StepForward} from "lucide-react"; +import { + ArrowDown, + FileX, + Loader2, + Pause, + SquareX, + StepForward, +} from "lucide-react"; import { Tooltip, TooltipContent, @@ -17,6 +24,7 @@ interface ActionButtonProps { icon: ReactNode; onClick: () => void; loading: boolean; + isMobile?: boolean; } const ActionButton = ({ @@ -24,10 +32,15 @@ const ActionButton = ({ icon, onClick, loading, + isMobile, }: ActionButtonProps) => ( - @@ -44,23 +57,32 @@ export default function FileControl({ isMobile, }: { file: TelegramFile; - downloadSpeed: number; + downloadSpeed?: number; hovered?: boolean; isMobile?: boolean; }) { const showDownloadInfo = !hovered && (file.downloadStatus === "downloading" || file.downloadStatus === "paused"); + const iconSize = isMobile ? "!h-3 !w-3" : "h-4 w-4"; - const { start, starting, togglePause, togglingPause, cancel, cancelling, remove, removing } = - useFileControl(file); + const { + start, + starting, + togglePause, + togglingPause, + cancel, + cancelling, + remove, + removing, + } = useFileControl(file); const statusMapping: Record = { idle: [ { onClick: () => start(file.id), tooltipText: "Start Download", - icon: , + icon: , loading: starting, }, ], @@ -68,7 +90,7 @@ export default function FileControl({ { onClick: () => start(file.id), tooltipText: "Retry", - icon: , + icon: , loading: starting, }, ], @@ -76,13 +98,13 @@ export default function FileControl({ { onClick: () => togglePause(file.id), tooltipText: "Pause", - icon: , + icon: , loading: togglingPause, }, { onClick: () => cancel(file.id), tooltipText: "Cancel", - icon: , + icon: , loading: cancelling, }, ], @@ -90,13 +112,13 @@ export default function FileControl({ { onClick: () => togglePause(file.id), tooltipText: "Resume", - icon: , + icon: , loading: togglingPause, }, { onClick: () => cancel(file.id), tooltipText: "Cancel", - icon: , + icon: , loading: cancelling, }, ], @@ -104,7 +126,7 @@ export default function FileControl({ { onClick: () => remove(file.id), tooltipText: "Remove", - icon: , + icon: , loading: removing, }, ], @@ -113,11 +135,11 @@ export default function FileControl({ const actionButtons = (
e.preventDefault()} > {statusMapping[file.downloadStatus].map((btnProps, index) => ( - + ))}
@@ -141,7 +163,7 @@ export default function FileControl({ className="absolute w-full" > - {file.downloadStatus === "downloading" + {file.downloadStatus === "downloading" && downloadSpeed ? `${prettyBytes(downloadSpeed, { bits: true })}/s` : prettyBytes(file.downloadedSize)} diff --git a/web/src/components/file-extra.tsx b/web/src/components/file-extra.tsx index 6ab72d6..8ee5371 100644 --- a/web/src/components/file-extra.tsx +++ b/web/src/components/file-extra.tsx @@ -19,6 +19,7 @@ import { type RowHeight } from "@/components/table-row-height-switch"; import { useCopyToClipboard } from "@/hooks/use-copy-to-clipboard"; import { formatDistanceToNow } from "date-fns"; import { cn } from "@/lib/utils"; +import useIsMobile from "@/hooks/use-is-mobile"; interface FileExtraProps { file: TelegramFile; @@ -76,6 +77,7 @@ function FileCaption({ file, rowHeight }: FileExtraProps) { function FilePath({ file }: { file: TelegramFile }) { const [, copyToClipboard] = useCopyToClipboard(); + const isMobile = useIsMobile(); if (!file.localPath) { return null; @@ -86,8 +88,11 @@ function FilePath({ file }: { file: TelegramFile }) {

copyToClipboard(file.localPath)} + className={cn( + "group line-clamp-1 cursor-pointer overflow-hidden truncate text-ellipsis text-wrap rounded px-1 hover:bg-gray-100 dark:hover:bg-gray-800", + isMobile && "px-0", + )} + onClick={() => !isMobile && copyToClipboard(file.localPath)} > {file.localPath.split("/").pop()} diff --git a/web/src/components/file-filters.tsx b/web/src/components/file-filters.tsx index 147c2bd..93f485b 100644 --- a/web/src/components/file-filters.tsx +++ b/web/src/components/file-filters.tsx @@ -26,16 +26,17 @@ import { Button } from "./ui/button"; import { DOWNLOAD_STATUS, TRANSFER_STATUS } from "@/components/file-status"; import * as SelectPrimitive from "@radix-ui/react-select"; import { cn } from "@/lib/utils"; +import useIsMobile from "@/hooks/use-is-mobile"; interface FileFiltersProps { telegramId: string; chatId: string; filters: FileFilter; onFiltersChange: (filters: FileFilter) => void; - columns: Column[]; - onColumnConfigChange: (config: Column[]) => void; - rowHeight: RowHeight; - setRowHeight: (e: RowHeight) => void; + columns?: Column[]; + onColumnConfigChange?: (config: Column[]) => void; + rowHeight?: RowHeight; + setRowHeight?: (e: RowHeight) => void; } const TableRowHeightSwitch = dynamic( @@ -62,11 +63,12 @@ export function FileFilters({ chatId, filters, onFiltersChange, - columns, - onColumnConfigChange, - rowHeight, - setRowHeight, + columns = [], + onColumnConfigChange = () => void 0, + rowHeight = "m", + setRowHeight = () => void 0, }: FileFiltersProps) { + const isMobile = useIsMobile(); const [search, setSearch] = useState(filters.search); const handleSearchChange = useDebouncedCallback((search: string) => { @@ -97,7 +99,6 @@ export function FileFilters({ const statusDisplayValue = useMemo(() => { if (!filters.downloadStatus && !filters.transferStatus) { - console.log("no status filter"); return "Filter Status"; } const downloadStatus = @@ -174,7 +175,8 @@ export function FileFilters({ key={`${groupKey}||${statusKey}`} value={`${groupKey}||${statusKey}`} className={cn( - "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none hover:bg-accent hover:text-accent-foreground focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + "relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none hover:bg-accent hover:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50", + !isMobile && "focus:bg-accent focus:text-accent-foreground" )} > {filters[groupKey as keyof FileFilter] === statusKey && ( @@ -196,16 +198,18 @@ export function FileFilters({

-
- - -
+ {!isMobile && ( +
+ + +
+ )} ); } diff --git a/web/src/components/file-progress.tsx b/web/src/components/file-progress.tsx deleted file mode 100644 index 3c3ca5c..0000000 --- a/web/src/components/file-progress.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { Progress } from "@/components/ui/progress"; -import { type TelegramFile } from "@/lib/types"; -import { useMemo } from "react"; - -export default function FileProgress({ - file, - downloadProgress, -}: { - file: TelegramFile; - downloadProgress: number; -}) { - const progress = useMemo(() => { - const fileDownloadProgress = - file.size > 0 - ? Math.min((file.downloadedSize / file.size) * 100, 100) - : 0; - if (file.downloadStatus === "downloading") { - return downloadProgress > 0 ? downloadProgress : fileDownloadProgress; - } - if (file.downloadStatus === "paused") { - return fileDownloadProgress; - } - return file.downloadStatus === "completed" ? 100 : 0; - }, [file.downloadStatus, file.downloadedSize, file.size, downloadProgress]); - - if ( - file.downloadStatus === "idle" || - file.downloadStatus === "completed" || - file.size === 0 - ) { - return null; - } - - return ( -
- -
- ); -} diff --git a/web/src/components/file-row.tsx b/web/src/components/file-row.tsx index 9887c44..5057b4f 100644 --- a/web/src/components/file-row.tsx +++ b/web/src/components/file-row.tsx @@ -1,27 +1,18 @@ import { cn } from "@/lib/utils"; import { type RowHeight } from "@/components/table-row-height-switch"; import { Checkbox } from "@/components/ui/checkbox"; -import FileProgress from "@/components/file-progress"; -import React, { memo, type ReactNode, useState } from "react"; +import React, { type ReactNode, useState } from "react"; import { type TelegramFile } from "@/lib/types"; -import SpoiledWrapper from "@/components/spoiled-wrapper"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "@/components/ui/tooltip"; -import PhotoPreview from "@/components/photo-preview"; import prettyBytes from "pretty-bytes"; import FileStatus from "@/components/file-status"; import FileExtra from "@/components/file-extra"; import FileControl from "@/components/file-control"; -import { FileAudioIcon, FileIcon, ImageIcon, VideoIcon } from "lucide-react"; -import Image from "next/image"; import { type Column } from "./table-column-filter"; import { useFileSpeed } from "@/hooks/use-file-speed"; +import FileAvatar from "@/components/file-avatar"; +import { Progress } from "@/components/ui/progress"; -interface FileRowProps { +type FileRowProps = { index: number; className?: string; style?: React.CSSProperties; @@ -37,7 +28,8 @@ interface FileRowProps { columns: Column[]; }; onCheckedChange: (checked: boolean) => void; -} + onFileClick: () => void; +}; export default function FileRow({ index, @@ -48,70 +40,19 @@ export default function FileRow({ checked, properties, onCheckedChange, + onFileClick, }: FileRowProps) { const { rowHeight, dynamicClass, columns } = properties; - const { downloadProgress, downloadSpeed } = useFileSpeed(file.id); + const { downloadProgress, downloadSpeed } = useFileSpeed(file); const [hovered, setHovered] = useState(false); - const getFileIcon = (type: TelegramFile["type"]) => { - let icon; - switch (type) { - case "photo": - icon = ; - break; - case "video": - icon = ; - break; - case "audio": - icon = ; - break; - default: - icon = ; - } - return ( -
- {icon} -
- ); - }; - const columnRenders: Record = { content: ( -
- {file.type === "photo" || file.type === "video" ? ( - file.thumbnail ? ( - - - - - - - - - - - - - ) : ( - getFileIcon(file.type) - ) - ) : ( - getFileIcon(file.type) - )} +
+
), type: ( @@ -165,29 +106,14 @@ export default function FileRow({ ) : null, )}
-
- -
+ {downloadProgress > 0 && downloadProgress !== 100 && ( +
+ +
+ )} ); } - -const PhotoColumnImage = memo(function PhotoColumnImage({ - thumbnail, - name, - wh, -}: { - thumbnail: string; - name: string; - wh: string; -}) { - return ( - {name - ); -}); diff --git a/web/src/components/file-status.tsx b/web/src/components/file-status.tsx index 74336bf..74d20e9 100644 --- a/web/src/components/file-status.tsx +++ b/web/src/components/file-status.tsx @@ -4,7 +4,14 @@ import React from "react"; import { cn } from "@/lib/utils"; import { TooltipWrapper } from "@/components/ui/tooltip"; import { AnimatePresence, motion } from "framer-motion"; -import {CheckCircle2, Clock, Download, FolderSync, Pause, XCircle} from "lucide-react"; +import { + CheckCircle2, + Clock, + Download, + FolderSync, + Pause, + XCircle, +} from "lucide-react"; export const DOWNLOAD_STATUS = { idle: { @@ -57,7 +64,13 @@ export const TRANSFER_STATUS = { }, }; -export default function FileStatus({ file }: { file: TelegramFile }) { +export default function FileStatus({ + file, + className, +}: { + file: TelegramFile; + className?: string; +}) { const badgeVariants = { initial: { opacity: 0, scale: 0.9 }, animate: { @@ -69,7 +82,9 @@ export default function FileStatus({ file }: { file: TelegramFile }) { }; return ( -
+
{file.transferStatus === "idle" && ( >(new Set()); - const observerTarget = useRef(null); const tableParentRef = useRef(null); const [columns, setColumns] = useState(COLUMNS); const [rowHeight, setRowHeight] = useRowHeightLocalStorage( "telegramFileList", "m", ); + const useFilesProps = useFiles(accountId, chatId); const { filters, handleFilterChange, isLoading, files, handleLoadMore } = - useFiles(accountId, chatId); + useFilesProps; + const [currentViewFile, setCurrentViewFile] = useState(); + const [viewerOpen, setViewerOpen] = useState(false); const { trigger: startDownloadMultiple, isMutating: startDownloadMultipleMutating, } = useSWRMutation( - "/file/start-download-multiple", + `/${accountId}/file/start-download-multiple`, ( key, { @@ -105,7 +106,6 @@ export function FileList({ accountId, chatId }: FileListProps) { }, paddingStart: 1, paddingEnd: 1, - overscan: 20, }); useEffect(() => { @@ -125,21 +125,19 @@ export function FileList({ accountId, chatId }: FileListProps) { }, [files.length, handleLoadMore, rowVirtual.getVirtualItems()]); useEffect(() => { - const observer = new IntersectionObserver( - (entries) => { - if (entries[0]?.isIntersecting) { - void handleLoadMore(); - } - }, - { threshold: 0.1 }, - ); - - if (observerTarget.current) { - observer.observe(observerTarget.current); + if (files.length === 0 || !currentViewFile) { + return; } - - return () => observer.disconnect(); - }, [handleLoadMore]); + const index = files.findIndex((f) => f.id === currentViewFile.id); + if (index === -1) { + setCurrentViewFile(undefined); + return; + } + const file = files[index]!; + if (currentViewFile.next === undefined && file.next !== undefined) { + setCurrentViewFile(file); + } + }, [currentViewFile, files]); const dynamicClass = useMemo(() => { switch (rowHeight) { @@ -161,41 +159,6 @@ export function FileList({ accountId, chatId }: FileListProps) { } }, [rowHeight]); - if (isMobile) { - return ( -
- -
- {files.map((file, index) => ( - - ))} -
-
- {isLoading && ( -
- -
- )} -
-
- ); - } - const handleSelectAll = () => { if (selectedFiles.size === files.length) { setSelectedFiles(new Set()); @@ -232,6 +195,15 @@ export function FileList({ accountId, chatId }: FileListProps) { rowHeight={rowHeight} setRowHeight={setRowHeight} /> + {currentViewFile && ( + + )}
{selectedFiles.size > 0 && (
@@ -328,6 +300,10 @@ export function FileList({ accountId, chatId }: FileListProps) { file={file} checked={selectedFiles.has(file.id)} onCheckedChange={() => handleSelectFile(file.id)} + onFileClick={() => { + setCurrentViewFile(file); + setViewerOpen(true); + }} properties={{ rowHeight: rowHeight, dynamicClass, diff --git a/web/src/components/file-viewer.tsx b/web/src/components/file-viewer.tsx new file mode 100644 index 0000000..c7e2c4d --- /dev/null +++ b/web/src/components/file-viewer.tsx @@ -0,0 +1,182 @@ +import { type TelegramFile } from "@/lib/types"; +import PhotoPreview from "@/components/photo-preview"; +import React, { useEffect } from "react"; +import { Dialog, DialogOverlay, DialogPortal, DialogTitle } from "./ui/dialog"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import * as DialogPrimitive from "@radix-ui/react-dialog"; +import { cn } from "@/lib/utils"; +import VideoPreview from "./video-preview"; +import { + ChevronLeft, + ChevronRight, + CircleX, + LoaderPinwheel, +} from "lucide-react"; +import { AnimatePresence, motion } from "framer-motion"; +import { type useFiles } from "@/hooks/use-files"; +import FileExtra from "@/components/file-extra"; +import { Button } from "@/components/ui/button"; +import useFileSwitch from "@/hooks/use-file-switch"; + +type FileViewerProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + file: TelegramFile; + onFileChange: (file: TelegramFile) => void; +} & ReturnType; + +export default function FileViewer({ + open, + onOpenChange, + onFileChange, + file, + hasMore, + handleLoadMore, + isLoading, +}: FileViewerProps) { + const { handleNavigation, direction } = useFileSwitch({ + file, + onFileChange, + hasMore, + handleLoadMore, + }); + + useEffect(() => { + console.log("FileViewer rendered", file); + }, [file]); + + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (file === undefined || !open) return; + + if (e.key === "ArrowLeft") { + handleNavigation(-1); + } else if (e.key === "ArrowRight") { + handleNavigation(1); + } + }; + + document.addEventListener("keydown", handleKeyDown); + return () => document.removeEventListener("keydown", handleKeyDown); + }, [handleNavigation, file, open]); + + const slideVariants = { + enter: (direction: number) => ({ + x: direction > 0 ? 1000 : -1000, + opacity: 0, + }), + center: { + zIndex: 1, + x: 0, + opacity: 1, + }, + exit: (direction: number) => ({ + zIndex: 0, + x: direction < 0 ? 1000 : -1000, + opacity: 0, + }), + }; + + if (!file) return null; + + return ( + + + + <> +
+ + +
+ {file.prev && ( +
handleNavigation(-1)} + > + +
+ )} + + {(file.next ?? hasMore) && ( +
handleNavigation(1)} + > + +
+ )} + +
+ + { + if (e.target instanceof Element) { + if (e.target.getAttribute("data-state")) { + onOpenChange(false); + } + } + e.preventDefault(); + }} + > +
+ + + + File Viewer + + {file.type === "video" && + file.downloadStatus === "completed" ? ( + + ) : ( + + )} + + +
+ {isLoading && ( +
+ +
+ )} +
+
+
+ ); +} diff --git a/web/src/components/files.tsx b/web/src/components/files.tsx new file mode 100644 index 0000000..ec043ac --- /dev/null +++ b/web/src/components/files.tsx @@ -0,0 +1,20 @@ +"use client"; +import FileList from "@/components/mobile/file-list"; +import { FileTable } from "@/components/file-table"; +import useIsMobile from "@/hooks/use-is-mobile"; + +export default function Files({ + accountId, + chatId, +}: { + accountId: string; + chatId: string; +}) { + const isMobile = useIsMobile(); + + if (isMobile) { + return ; + } else { + return ; + } +} diff --git a/web/src/components/header.tsx b/web/src/components/header.tsx index ae62a00..68c4dcb 100644 --- a/web/src/components/header.tsx +++ b/web/src/components/header.tsx @@ -119,11 +119,11 @@ export function Header() { -
- +
+ {`${prettyBytes(accountDownloadSpeed, { bits: true })}/s`} - +
diff --git a/web/src/components/mobile/file-card.tsx b/web/src/components/mobile/file-card.tsx new file mode 100644 index 0000000..d581816 --- /dev/null +++ b/web/src/components/mobile/file-card.tsx @@ -0,0 +1,74 @@ +import type { TelegramFile } from "@/lib/types"; +import { useFileSpeed } from "@/hooks/use-file-speed"; +import { Card, CardContent } from "@/components/ui/card"; +import { cn } from "@/lib/utils"; +import FileAvatar from "@/components/file-avatar"; +import prettyBytes from "pretty-bytes"; +import FileStatus from "@/components/file-status"; +import FileControl from "@/components/file-control"; +import React from "react"; +import FileExtra from "@/components/file-extra"; + +type FileCardProps = { + index: number; + className?: string; + style?: React.CSSProperties; + ref?: React.Ref; + file: TelegramFile; + onFileClick: () => void; +}; + +export function FileCard({ + index, + className, + style, + ref, + file, + onFileClick, +}: FileCardProps) { + const { downloadProgress } = useFileSpeed(file); + return ( + 0 && downloadProgress !== 100 + ? `before:w-progress` + : "before:w-0", + className, + )} + style={{ + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-expect-error + "--tw-progress-width": `${downloadProgress > 0 && downloadProgress !== 100 ? downloadProgress.toFixed(0) + "%" : "0"}`, + ...style, + }} + onClick={onFileClick} + > + +
+ +
+ +
+
+ + {prettyBytes(file.size)} • {file.type} + + +
+ +
e.stopPropagation()} + > + +
+
+
+
+
+
+ ); +} diff --git a/web/src/components/mobile/file-drawer.tsx b/web/src/components/mobile/file-drawer.tsx new file mode 100644 index 0000000..2062e71 --- /dev/null +++ b/web/src/components/mobile/file-drawer.tsx @@ -0,0 +1,179 @@ +import type { TelegramFile } from "@/lib/types"; +import React, { useEffect, useState } from "react"; +import { Drawer, DrawerContent, DrawerTitle } from "@/components/ui/drawer"; +import { cn } from "@/lib/utils"; +import { VisuallyHidden } from "@radix-ui/react-visually-hidden"; +import { LoaderPinwheel, Minimize2 } from "lucide-react"; +import { AnimatePresence, motion } from "framer-motion"; +import { Button } from "@/components/ui/button"; +import VideoPreview from "@/components/video-preview"; +import PhotoPreview from "@/components/photo-preview"; +import FileInfo from "@/components/mobile/file-info"; +import { type useFiles } from "@/hooks/use-files"; +import useFileSwitch from "@/hooks/use-file-switch"; + +type FileDrawerProps = { + open: boolean; + onOpenChange: (open: boolean) => void; + file: TelegramFile; + onFileChange: (file: TelegramFile) => void; +} & ReturnType; + +export default function FileDrawer({ + open, + onOpenChange, + file, + onFileChange, + hasMore, + handleLoadMore, + isLoading, +}: FileDrawerProps) { + const [viewing, setViewing] = useState(false); + + const { handleNavigation, direction } = useFileSwitch({ + file, + onFileChange, + hasMore, + handleLoadMore, + }); + + useEffect(() => { + if ( + viewing && + (file.downloadStatus !== "completed" || + (file.type !== "video" && file.type !== "photo")) + ) { + setViewing(false); + } + }, [file, viewing]); + + useEffect(() => { + const handleTouchStart = (e: TouchEvent) => { + const touch = e.touches[0]; + if (!touch) return; + const x = touch.clientX; + const y = touch.clientY; + + const handleTouchEnd = (e: TouchEvent) => { + const touch = e.changedTouches[0]; + if (!touch) return; + const dx = touch.clientX - x; + const dy = touch.clientY - y; + + if (Math.abs(dx) > Math.abs(dy)) { + if (dx > 0) { + handleNavigation(-1); + } else if (dx < 0) { + handleNavigation(1); + } + } + document.removeEventListener("touchend", handleTouchEnd); + }; + document.addEventListener("touchend", handleTouchEnd); + }; + document.addEventListener("touchstart", handleTouchStart); + return () => document.removeEventListener("touchstart", handleTouchStart); + }, [handleNavigation, file]); + + const slideVariants = { + enter: (direction: number) => ({ + x: direction > 0 ? 500 : -500, + opacity: 0, + }), + center: { + zIndex: 1, + x: 0, + opacity: 1, + }, + exit: (direction: number) => ({ + zIndex: 0, + x: direction < 0 ? 500 : -500, + opacity: 0, + }), + }; + + if (!file) return null; + + return ( + { + if (!open && viewing) { + setViewing(false); + return; + } + onOpenChange(open); + }} + > + + + File Details + + {isLoading && ( +
+ +
+ )} + + + {viewing ? ( +
+
+ +
+ {file.type === "video" && + file.downloadStatus === "completed" ? ( + + ) : ( + + )} +
+ ) : ( + setViewing(true)} file={file} /> + )} +
+
+
+
+ ); +} diff --git a/web/src/components/mobile/file-info.tsx b/web/src/components/mobile/file-info.tsx new file mode 100644 index 0000000..9a09374 --- /dev/null +++ b/web/src/components/mobile/file-info.tsx @@ -0,0 +1,157 @@ +import type { TelegramFile } from "@/lib/types"; +import { useFileSpeed } from "@/hooks/use-file-speed"; +import { + DrawerDescription, + DrawerFooter, + DrawerHeader, +} from "@/components/ui/drawer"; +import FileAvatar from "@/components/file-avatar"; +import FileStatus from "@/components/file-status"; +import SpoiledWrapper from "@/components/spoiled-wrapper"; +import { Progress } from "@/components/ui/progress"; +import { Separator } from "@/components/ui/separator"; +import { + Calendar, + Clock10, + ClockArrowDown, + Download, + HardDrive, + Type, + View, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import prettyBytes from "pretty-bytes"; +import { formatDistanceToNow } from "date-fns"; +import { Button } from "@/components/ui/button"; +import { MobileFileControl } from "@/components/file-control"; +import React from "react"; + +export default function FileInfo({ + file, + onView, +}: { + file: TelegramFile; + onView: () => void; +}) { + const { downloadProgress } = useFileSpeed(file); + return ( + <> + +
+ + {file.fileName} +
+
+ +
+ {file.caption && ( + + "), + }} + > + + )} + {downloadProgress > 0 && downloadProgress !== 100 && ( +
+ +
+ )} +
+ +
+ +
+ +
+
+
+
+ + Type +
+ + {file.type} + +
+ +
+
+ + Size +
+ + {prettyBytes(file.size)} + +
+ +
+
+ + Received At +
+ + {formatDistanceToNow(new Date(file.date * 1000), { + addSuffix: true, + })} + +
+ +
+
+ + Downloaded Size +
+ + {prettyBytes(file.downloadedSize)} + +
+ + {file.downloadStatus !== "idle" && file.startDate !== 0 && ( +
+
+ + Download At +
+ + {formatDistanceToNow(new Date(file.startDate), { + addSuffix: true, + })} + +
+ )} + + {file.completionDate && ( +
+
+ + Completion At +
+ + {formatDistanceToNow(new Date(file.completionDate), { + addSuffix: true, + })} + +
+ )} +
+
+ + + {file.downloadStatus === "completed" && + (file.type === "video" || file.type === "photo") && ( + + )} + + + + ); +} diff --git a/web/src/components/mobile/file-list.tsx b/web/src/components/mobile/file-list.tsx new file mode 100644 index 0000000..4fb8ae6 --- /dev/null +++ b/web/src/components/mobile/file-list.tsx @@ -0,0 +1,139 @@ +import { FileFilters } from "@/components/file-filters"; +import { LoaderPinwheel } from "lucide-react"; +import React, { useEffect, useState } from "react"; +import { useFiles } from "@/hooks/use-files"; +import { useWindowVirtualizer } from "@tanstack/react-virtual"; +import { FileCard } from "@/components/mobile/file-card"; +import { cn } from "@/lib/utils"; +import FileDrawer from "@/components/mobile/file-drawer"; +import type { TelegramFile } from "@/lib/types"; +import { isEqual } from "lodash"; + +interface FileListProps { + accountId: string; + chatId: string; +} + +export default function FileList({ accountId, chatId }: FileListProps) { + const useFilesProps = useFiles(accountId, chatId); + const [currentViewFile, setCurrentViewFile] = useState< + TelegramFile | undefined + >(); + const [isDrawerOpen, setIsDrawerOpen] = useState(false); + + const { + filters, + handleFilterChange, + isLoading, + files, + hasMore, + handleLoadMore, + } = useFilesProps; + + const rowVirtual = useWindowVirtualizer({ + count: hasMore ? files.length + 1 : files.length, + estimateSize: () => 90, + overscan: 5, + scrollMargin: 0, + gap: 10, + }); + + useEffect(() => { + const [lastItem] = [...rowVirtual.getVirtualItems()].reverse(); + if (!lastItem) { + return; + } + + if (lastItem.index >= files.length - 1 && hasMore && !isLoading) { + void handleLoadMore(); + } + //eslint-disable-next-line + }, [files.length, handleLoadMore, rowVirtual.getVirtualItems()]); + + useEffect(() => { + if (files.length === 0 || !currentViewFile) { + return; + } + const index = files.findIndex((f) => f.id === currentViewFile.id); + if (index === -1) { + setCurrentViewFile(undefined); + return; + } + const file = files[index]!; + if (!isEqual(file, currentViewFile)) { + setCurrentViewFile(file); + } + }, [currentViewFile, files]); + + return ( +
+ + {currentViewFile && ( + + )} +
+ {files.length !== 0 && + rowVirtual.getVirtualItems().map((virtualRow) => { + const isLoaderRow = virtualRow.index > files.length - 1; + const file = files[virtualRow.index]!; + if (isLoaderRow) { + return ( +
+ {hasMore ? ( + + ) : ( +

No more files

+ )} +
+ ); + } + return ( + { + setCurrentViewFile(file); + setIsDrawerOpen(true); + }} + {...useFilesProps} + /> + ); + })} +
+
+ ); +} diff --git a/web/src/components/photo-preview.tsx b/web/src/components/photo-preview.tsx index b5cb49a..2fbdfb6 100644 --- a/web/src/components/photo-preview.tsx +++ b/web/src/components/photo-preview.tsx @@ -1,169 +1,92 @@ "use client"; import Image from "next/image"; -import useSWR from "swr"; -import { CloudAlert, Loader } from "lucide-react"; -import { useWebsocket } from "@/hooks/use-websocket"; -import {useEffect, useRef, useState} from "react"; -import { WebSocketMessageType } from "@/lib/websocket-types"; -import { toast } from "@/hooks/use-toast"; -import { useSettings } from "@/hooks/use-settings"; -import { type TDFile } from "@/lib/types"; +import { type TelegramFile } from "@/lib/types"; import { getApiUrl } from "@/lib/api"; -import useIsMobile from "@/hooks/use-is-mobile"; +import { useEffect, useState } from "react"; +import { ImageOff } from "lucide-react"; +import { cn } from "@/lib/utils"; + +const ImageErrorFallback = ({ + className = "", + message = "Image loading failed!", +}) => ( +
+ +

{message}

+
+); interface PhotoPreviewProps { - thumbnail: string; - name: string; - chatId: number; - messageId: number; + className?: string; + file: TelegramFile; } -export default function PhotoPreview({ - thumbnail, - name, - chatId, - messageId, -}: PhotoPreviewProps) { - const url = `${getApiUrl()}/file/preview?chatId=${chatId}&messageId=${messageId}`; - const { settings } = useSettings(); - const isMobile = useIsMobile(); - const [isReady, setIsReady] = useState(false); - const imageRef = useRef(null); - const [fileStatus, setFileStatus] = useState<{ - fileId: number | null; - readyFileIds: number[]; - }>({ - fileId: null, - readyFileIds: [], - }); - const { lastJsonMessage } = useWebsocket(); - - const { error } = useSWR( - settings?.needToLoadImages === "true" ? url : null, - (url: string) => - fetch(url, { credentials: "include" }).then(async (res) => { - if (!res.ok) { - let message = "Failed to fetch, status: " + res.status; - try { - const { error } = await res.json() as { error: string }; - if (error) { - message = error; - } - // eslint-disable-next-line @typescript-eslint/no-unused-vars - } catch (e) { - // do nothing - } - toast({ - title: "Error", - description: message, - variant: "destructive", - }); - throw new Error(message); - } - if (res.headers.get("Content-Type") === "application/json") { - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment - const json: { fileId: number } = await res.json(); - if (json.fileId) { - setFileStatus((prev) => ({ - ...prev, - fileId: json.fileId, - })); - } - } else { - setIsReady(true); - } - }), - { - revalidateOnFocus: false, - revalidateOnReconnect: false, - revalidateOnMount: true, - }, - ); +export default function PhotoPreview({ className, file }: PhotoPreviewProps) { + const [viewportHeight, setViewportHeight] = useState(0); + const [error, setError] = useState(false); + const src = + !file.localPath || file.type === "video" + ? `data:image/jpeg;base64,${file.thumbnail}` + : `${getApiUrl()}/${file.telegramId}/file/${file.uniqueId}`; useEffect(() => { - if ( - lastJsonMessage !== null && - lastJsonMessage.type === WebSocketMessageType.FILE_UPDATE - ) { - const { file } = lastJsonMessage.data as { file: TDFile }; - if (file.local?.isDownloadingCompleted) { - setFileStatus((prev) => ({ - ...prev, - readyFileIds: [...prev.readyFileIds, file.id], - })); - } - } - }, [lastJsonMessage]); + setViewportHeight(window.innerHeight); - useEffect(() => { - const { fileId, readyFileIds } = fileStatus; - if (fileId !== null && readyFileIds.includes(fileId)) { - setIsReady(true); - } - }, [fileStatus]); + const handleResize = () => { + setViewportHeight(window.innerHeight); + }; - const ThumbnailImage = ( -
- {name -
- ); + window.addEventListener("resize", handleResize); + return () => window.removeEventListener("resize", handleResize); + }, []); - if (settings?.needToLoadImages !== "true") { - return ThumbnailImage; + const handleError = () => { + setError(true); + }; + + if (error) { + return ; } - if (!isReady || error) { + if (file?.extra?.width && file?.extra?.height && file?.localPath) { + const aspectRatio = file.extra.width / file.extra.height; + const maxHeight = viewportHeight; + const calculatedHeight = Math.min(file.extra.height, maxHeight); + const calculatedWidth = calculatedHeight * aspectRatio; + return ( -
- {ThumbnailImage} -
- {error ? ( - - ) : ( - !isReady && - )} -
+
+ {file.fileName
); } - const toggleFullScreen = () => { - if (!isMobile) return; - if (!document.fullscreenEnabled) { - console.error("Full-screen mode is not supported."); - return; - } - const image = imageRef.current; - if (!image) return; - if (!document.fullscreenElement) { - image.requestFullscreen().catch((err: Error) => { - console.error(`Error attempting to enable full-screen mode: ${err.message}`) - }); - } else { - if (document.exitFullscreen) { - void document.exitFullscreen(); - } - } - }; - return ( -
+
{name
); diff --git a/web/src/components/settings-form.tsx b/web/src/components/settings-form.tsx index ca924bc..f1c5d60 100644 --- a/web/src/components/settings-form.tsx +++ b/web/src/components/settings-form.tsx @@ -20,18 +20,6 @@ export default function SettingsForm() { const { account } = useTelegramAccount(); const [, copyToClipboard] = useCopyToClipboard(); - const imageLoadSizeOptions = [ - { value: "s", label: "box 100x100" }, - { value: "m", label: "box 320x320" }, - { value: "x", label: "box 800x800" }, - { value: "y", label: "box 1280x1280" }, - { value: "w", label: "box 2560x2560" }, - { value: "a", label: "crop 160x160" }, - { value: "b", label: "crop 320x320" }, - { value: "c", label: "crop 640x640" }, - { value: "d", label: "crop 1280x1280" }, - ]; - const avgSpeedIntervalOptions = [ { value: "60", label: "1 minute" }, { value: "300", label: "5 minutes" }, @@ -91,43 +79,6 @@ export default function SettingsForm() { form will be inaccurate.

-
-
- - { - void setSetting("needToLoadImages", String(checked)); - }} - /> -
- {settings?.needToLoadImages === "true" && ( -
- - -

- The size of the image to load in the browser. -

-
- )} -
diff --git a/web/src/components/ui/drawer.tsx b/web/src/components/ui/drawer.tsx index 350f8b6..8f02c33 100644 --- a/web/src/components/ui/drawer.tsx +++ b/web/src/components/ui/drawer.tsx @@ -48,7 +48,6 @@ const DrawerContent = React.forwardRef< )} {...props} > -
{children} diff --git a/web/src/components/ui/slider.tsx b/web/src/components/ui/slider.tsx new file mode 100644 index 0000000..17a8cd8 --- /dev/null +++ b/web/src/components/ui/slider.tsx @@ -0,0 +1,28 @@ +"use client"; + +import * as React from "react"; +import * as SliderPrimitive from "@radix-ui/react-slider"; + +import { cn } from "@/lib/utils"; + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); +Slider.displayName = SliderPrimitive.Root.displayName; + +export { Slider }; diff --git a/web/src/components/video-preview.tsx b/web/src/components/video-preview.tsx new file mode 100644 index 0000000..01c6670 --- /dev/null +++ b/web/src/components/video-preview.tsx @@ -0,0 +1,571 @@ +import { type TelegramFile } from "@/lib/types"; +import React, { useEffect, useRef, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { AnimatePresence, motion } from "framer-motion"; +import { + Maximize, + Minimize, + Pause, + Play, + RotateCcw, + RotateCw, + VideoOff, + Volume2, + VolumeX, +} from "lucide-react"; +import { getApiUrl } from "@/lib/api"; +import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover"; +import { cn } from "@/lib/utils"; +import useIsMobile from "@/hooks/use-is-mobile"; +import * as SliderPrimitive from "@radix-ui/react-slider"; + +const VideoErrorFallback = ({ + className = "", + message = "Video loading failed!", +}) => ( +
+ +

{message}

+
+); + +const Slider = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + + +)); + +Slider.displayName = "Slider"; + +const DesktopControls = ({ + isPlaying, + currentTime, + duration, + volume, + isMuted, + isFullscreen, + playbackRate, + onPlayPause, + onVolumeChange, + onMuteToggle, + onFullscreenToggle, + onPlaybackRateChange, + onSeek, + progressBarRef, + onProgressBarHover, + onProgressBarLeave, + showPreview, + previewTime, + previewPos, + canvasRef, +}: { + isPlaying: boolean; + currentTime: number; + duration: number; + volume: number; + isMuted: boolean; + isFullscreen: boolean; + playbackRate: number; + onPlayPause: () => void; + onVolumeChange: (volume: number) => void; + onMuteToggle: () => void; + onFullscreenToggle: () => void; + onPlaybackRateChange: (rate: number) => void; + onSeek: (time: number) => void; + progressBarRef: React.RefObject; + onProgressBarHover: (e: React.MouseEvent) => void; + onProgressBarLeave: () => void; + showPreview: boolean; + previewTime: number; + previewPos: number; + canvasRef: React.RefObject; +}) => { + const playbackRates = [0.5, 0.75, 1, 1.25, 1.5, 2]; + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + return ( +
+
+ value[0] && onSeek(value[0])} + /> + {showPreview && ( + +
+ +
+
+ {formatTime(previewTime)} +
+
+ )} +
+ +
+ + +
+ + onVolumeChange(value[0]! / 100)} + /> +
+ +
+ {formatTime(currentTime)} / {formatTime(duration)} +
+ +
+ + + + + +
+ {playbackRates.map((rate) => ( + + ))} +
+
+
+ + +
+
+
+ ); +}; + +const MobileControls = ({ + isPlaying, + currentTime, + duration, + onPlayPause, + onSeek, + onSkipForward, + onSkipBackward, +}: { + isPlaying: boolean; + currentTime: number; + duration: number; + onPlayPause: () => void; + onSeek: (time: number) => void; + onSkipForward: () => void; + onSkipBackward: () => void; +}) => { + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60); + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + return ( +
e.stopPropagation()}> +
+ {formatTime(currentTime)} + {formatTime(duration)} +
+ + value[0] && onSeek(value[0])} + /> + +
+ + + + + +
+
+ ); +}; + +const VideoPreview = ({ + file, + onTimeUpdate, + onVolumeChange, + className, +}: { + file: TelegramFile; + onTimeUpdate?: (time: number) => void; + onVolumeChange?: (volume: number) => void; + className?: string; +}) => { + const videoRef = useRef(null); + const previewVideoRef = useRef(null); + const canvasRef = useRef(null); + const containerRef = useRef(null); + const progressBarRef = useRef(null); + const isMobile = useIsMobile(); + const [isPlaying, setIsPlaying] = useState(false); + const [currentTime, setCurrentTime] = useState(0); + const [duration, setDuration] = useState(0); + const [volume, setVolume] = useState(1); + const [isMuted, setIsMuted] = useState(false); + const [isFullscreen, setIsFullscreen] = useState(false); + const [showControls, setShowControls] = useState(true); + const [playbackRate, setPlaybackRate] = useState(1); + const [showPreview, setShowPreview] = useState(false); + const [previewTime, setPreviewTime] = useState(0); + const [previewPos, setPreviewPos] = useState(0); + const [isPreviewReady, setIsPreviewReady] = useState(false); + const [error, setError] = useState(false); + + const url = `${getApiUrl()}/${file.telegramId}/file/${file.uniqueId}`; + + useEffect(() => { + const video = videoRef.current; + const previewVideo = previewVideoRef.current; + if (!video || !previewVideo) return; + + const handleLoadedMetadata = () => { + setDuration(video.duration); + setIsPreviewReady(true); + }; + + video.addEventListener("loadedmetadata", handleLoadedMetadata); + previewVideo.addEventListener("loadedmetadata", handleLoadedMetadata); + + return () => { + video.removeEventListener("loadedmetadata", handleLoadedMetadata); + previewVideo.removeEventListener("loadedmetadata", handleLoadedMetadata); + }; + }, []); + + const captureVideoFrame = () => { + const previewVideo = previewVideoRef.current; + const canvas = canvasRef.current; + if (!previewVideo || !canvas || !isPreviewReady) return; + + const context = canvas.getContext("2d"); + if (!context) return; + + // Set canvas dimensions to match video dimensions + canvas.width = previewVideo.videoWidth; + canvas.height = previewVideo.videoHeight; + + // Draw the current frame + context.drawImage(previewVideo, 0, 0, canvas.width, canvas.height); + }; + + useEffect(() => { + if (showPreview) { + const timeoutId = setTimeout(captureVideoFrame, 150); // Add slight delay for frame to load + return () => clearTimeout(timeoutId); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [previewTime, showPreview]); + + const handleSkipForward = () => { + if (videoRef.current) { + videoRef.current.currentTime = Math.min(duration, currentTime + 15); + } + }; + + const handleSkipBackward = () => { + if (videoRef.current) { + videoRef.current.currentTime = Math.max(0, currentTime - 15); + } + }; + + const handleProgressBarHover = (e: React.MouseEvent) => { + if (isMobile) return; + + const progressBar = progressBarRef.current; + const previewVideo = previewVideoRef.current; + if (!progressBar || !previewVideo || !isPreviewReady) return; + + const rect = progressBar.getBoundingClientRect(); + const percent = Math.max( + 0, + Math.min(1, (e.clientX - rect.left) / rect.width), + ); + const previewTimeValue = percent * duration; + + setPreviewTime(previewTimeValue); + setPreviewPos(e.clientX - rect.left); + setShowPreview(true); + previewVideo.currentTime = previewTimeValue; + }; + + const togglePlay = () => { + if (videoRef.current) { + if (isPlaying) { + videoRef.current.pause(); + } else { + void videoRef.current.play(); + } + setIsPlaying(!isPlaying); + } + }; + + const handleSeek = (time: number) => { + if (videoRef.current) { + videoRef.current.currentTime = time; + setCurrentTime(time); + } + }; + + const handleTimeUpdate = () => { + if (videoRef.current) { + setCurrentTime(videoRef.current.currentTime); + onTimeUpdate?.(videoRef.current.currentTime); + } + }; + + const handleVolumeChange = (newVolume: number) => { + if (videoRef.current) { + videoRef.current.volume = newVolume; + setVolume(newVolume); + setIsMuted(newVolume === 0); + onVolumeChange?.(newVolume); + } + }; + + const toggleMute = () => { + if (videoRef.current) { + const newMuted = !isMuted; + videoRef.current.muted = newMuted; + setIsMuted(newMuted); + if (newMuted) { + handleVolumeChange(0); + } else { + handleVolumeChange(1); + } + } + }; + + const toggleFullscreen = () => { + if (!containerRef.current) return; + + if (!document.fullscreenElement) { + void containerRef.current.requestFullscreen(); + setIsFullscreen(true); + } else { + void document.exitFullscreen(); + setIsFullscreen(false); + } + }; + + const handlePlaybackRateChange = (rate: number) => { + if (videoRef.current) { + videoRef.current.playbackRate = rate; + setPlaybackRate(rate); + } + }; + + const handleEnded = () => { + setCurrentTime(0); + setIsPlaying(false); + }; + + if (error) { + return ; + } + + return ( + setShowControls((prev) => !prev) : undefined} + onMouseEnter={() => setShowControls(true)} + onMouseLeave={() => setShowControls(false)} + > + + ); +}; + +export default VideoPreview; diff --git a/web/src/hooks/use-file-control.tsx b/web/src/hooks/use-file-control.tsx index f8d8d53..83398b0 100644 --- a/web/src/hooks/use-file-control.tsx +++ b/web/src/hooks/use-file-control.tsx @@ -5,24 +5,24 @@ import { toast } from "@/hooks/use-toast"; export function useFileControl(file: TelegramFile) { const { trigger: startDownload, isMutating: starting } = useSWRMutation( - "/file/start-download", + `/${file.telegramId}/file/start-download`, ( key, { arg }: { arg: { chatId: number; messageId: number; fileId: number } }, ) => POST(key, arg), ); const { trigger: cancelDownload, isMutating: cancelling } = useSWRMutation( - "/file/cancel-download", + `/${file.telegramId}/file/cancel-download`, (key, { arg }: { arg: { fileId: number } }) => POST(key, arg), ); const { trigger: togglePauseDownload, isMutating: togglingPause } = useSWRMutation( - "/file/toggle-pause-download", + `/${file.telegramId}/file/toggle-pause-download`, (key, { arg }: { arg: { fileId: number; isPaused: boolean } }) => POST(key, arg), ); const { trigger: removeFile, isMutating: removing } = useSWRMutation( - "/file/remove", + `/${file.telegramId}/file/remove`, (key, { arg }: { arg: { fileId: number } }) => POST(key, arg), ); diff --git a/web/src/hooks/use-file-speed.ts b/web/src/hooks/use-file-speed.ts index ae21029..329960f 100644 --- a/web/src/hooks/use-file-speed.ts +++ b/web/src/hooks/use-file-speed.ts @@ -1,11 +1,12 @@ import { useWebsocket } from "@/hooks/use-websocket"; -import { useEffect, useState } from "react"; +import {useEffect, useMemo, useState} from "react"; import { WebSocketMessageType } from "@/lib/websocket-types"; -import type { TDFile } from "@/lib/types"; +import type {TDFile, TelegramFile} from "@/lib/types"; import { env } from "@/env"; import { useDebounce } from "use-debounce"; -export function useFileSpeed(fileId: number) { +export function useFileSpeed(file: TelegramFile) { + const fileId = file.id; const { lastJsonMessage } = useWebsocket(); const [downloadProgress, setDownloadProgress] = useState(0); const [downloadSpeed, setDownloadSpeed] = useState({ @@ -19,11 +20,25 @@ export function useFileSpeed(fileId: number) { maxWait: 1000, }); - const [debounceProgress] = useDebounce(downloadProgress, 300, { + const [debounceProgress] = useDebounce(downloadProgress, 100, { leading: true, maxWait: 1000, }); + const progress = useMemo(() => { + const fileDownloadProgress = + file.size > 0 + ? Math.min((file.downloadedSize / file.size) * 100, 100) + : 0; + if (file.downloadStatus === "downloading") { + return debounceProgress > 0 ? debounceProgress : fileDownloadProgress; + } + if (file.downloadStatus === "paused") { + return fileDownloadProgress; + } + return file.downloadStatus === "completed" ? 100 : 0; + }, [file.downloadStatus, file.downloadedSize, file.size, debounceProgress]); + useEffect(() => { if ( lastJsonMessage !== null && @@ -99,7 +114,7 @@ export function useFileSpeed(fileId: number) { }, []); return { - downloadProgress: debounceProgress, + downloadProgress: progress, downloadSpeed: debounceSpeed, }; } diff --git a/web/src/hooks/use-file-switch.ts b/web/src/hooks/use-file-switch.ts new file mode 100644 index 0000000..90add75 --- /dev/null +++ b/web/src/hooks/use-file-switch.ts @@ -0,0 +1,43 @@ +import { useCallback, useState } from "react"; +import { toast } from "@/hooks/use-toast"; +import type { TelegramFile } from "@/lib/types"; + +type FileSwitchProps = { + file: TelegramFile; + onFileChange: (file: TelegramFile) => void; + hasMore: boolean; + handleLoadMore: () => Promise; +}; + +export default function useFileSwitch({ + file, + onFileChange, + hasMore, + handleLoadMore, +}: FileSwitchProps) { + const [direction, setDirection] = useState(0); + + const handleNavigation = useCallback( + (newDirection: number) => { + const newFile = newDirection > 0 ? file?.next : file?.prev; + if (newFile) { + setDirection(newDirection); + onFileChange(newFile); + } else if (newDirection > 0) { + if (hasMore) { + void handleLoadMore(); + } else { + toast({ + description: "No more files to load", + }); + } + } + }, + [file?.next, file?.prev, onFileChange, hasMore, handleLoadMore], + ); + + return { + direction, + handleNavigation, + }; +} diff --git a/web/src/hooks/use-files.ts b/web/src/hooks/use-files.ts index b2e3e1b..1bb9cc2 100644 --- a/web/src/hooks/use-files.ts +++ b/web/src/hooks/use-files.ts @@ -154,6 +154,10 @@ export function useFiles(accountId: string, chatId: string) { }); }); }); + files.forEach((file, index) => { + file.prev = files[index - 1]; + file.next = files[index + 1]; + }); return files; }, [pages, latestFilesStatus]); diff --git a/web/src/hooks/use-telegram-chat.tsx b/web/src/hooks/use-telegram-chat.tsx index 22e1286..40d095b 100644 --- a/web/src/hooks/use-telegram-chat.tsx +++ b/web/src/hooks/use-telegram-chat.tsx @@ -65,10 +65,6 @@ export const TelegramChatProvider: React.FC = ({ return; } router.push(`/account/${accountId}/${newChatId}`); - toast({ - title: "Chat Selected", - description: `Viewing files from ${chat.name}`, - }); }; return ( diff --git a/web/src/lib/types.ts b/web/src/lib/types.ts index ca063ef..a65eabd 100644 --- a/web/src/lib/types.ts +++ b/web/src/lib/types.ts @@ -35,6 +35,7 @@ export type TransferStatus = "idle" | "transferring" | "completed" | "error"; export type TelegramFile = { id: number; + telegramId: number; uniqueId: string; messageId: number; chatId: number; @@ -52,6 +53,23 @@ export type TelegramFile = { startDate: number; completionDate: number; transferStatus?: TransferStatus; + extra?: PhotoExtra | VideoExtra; + + prev?: TelegramFile; + next?: TelegramFile; +}; + +export type PhotoExtra = { + width: number; + height: number; + type: string; +}; + +export type VideoExtra = { + width: number; + height: number; + duration: number; + mimeType: string; }; export type TDFile = { @@ -90,7 +108,6 @@ export type TelegramApiResult = { export const SettingKeys = [ "uniqueOnly", - "needToLoadImages", "imageLoadSize", "alwaysHide", "showSensitiveContent", diff --git a/web/src/styles/globals.css b/web/src/styles/globals.css index 000e9a8..4d34527 100644 --- a/web/src/styles/globals.css +++ b/web/src/styles/globals.css @@ -64,3 +64,7 @@ @apply bg-background text-foreground; } } + +.before\:w-progress::before { + width: var(--tw-progress-width); +} diff --git a/web/tailwind.config.ts b/web/tailwind.config.ts index 998ad36..24b9a12 100644 --- a/web/tailwind.config.ts +++ b/web/tailwind.config.ts @@ -117,6 +117,9 @@ export default { "5": "hsl(var(--chart-5))", }, }, + width: { + progress: "var(--tw-progress-width)", + }, }, }, plugins: [tailwindcss_animate],