feat: Remove the loading image function and add the function of previewing downloaded images and videos

This commit is contained in:
jarvis2f 2025-02-12 23:13:06 +08:00
parent 8f48c7939f
commit 69d57b842d
38 changed files with 2213 additions and 847 deletions

View file

@ -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());
}
});
}
}
}
}

View file

@ -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<TelegramVerticle> 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<TelegramVerticle> getTelegramVerticle(long telegramId) {
return telegramVerticles.stream()
.filter(t -> t.telegramRecord != null && t.telegramRecord.id() == telegramId)

View file

@ -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<TdApi.MessagePhoto> {
@ -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<TdApi.MessageVideo> {
@ -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<TdApi.MessageAudio> {

View file

@ -185,17 +185,10 @@ public class TelegramVerticle extends AbstractVerticle {
Map<Long, TdApi.Message> messageMap = Arrays.stream(m.messages)
.filter(Objects::nonNull)
.collect(Collectors.toMap(message -> message.id, Function.identity()));
List<FileRecord> 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<JsonObject> 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<Object> 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<Tuple2<String, String>> 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<? extends TdApi.MessageContent> 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<Void> 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<TdApi.File> 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<JsonObject> 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<? extends TdApi.MessageContent> 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<JsonObject> convertRangedSpeedStats(List<StatisticRecord> statisticRecords, int timeRange) {
TreeMap<String, List<JsonObject>> groupedSpeedStats = new TreeMap<>(Comparator.comparing(
switch (timeRange) {

View file

@ -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),

202
web/package-lock.json generated
View file

@ -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",

View file

@ -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",

View file

@ -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 (
<div className="container mx-auto px-4 py-6">
<Header />
<FileList accountId={accountId} chatId={chatId} />
<Files accountId={accountId} chatId={chatId} />
</div>
);
}

View file

@ -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);
},

View file

@ -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 <ImageIcon className="h-6 w-6" />;
case "video":
return <VideoIcon className="h-6 w-6" />;
case "audio":
return <FileAudio2Icon className="h-6 w-6" />;
default:
return <FileIcon className="h-6 w-6" />;
}
};
return (
<>
{file.thumbnail ? (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
<Image
src={`data:image/jpeg;base64,${file.thumbnail}`}
alt={file.fileName ?? "File thumbnail"}
width={32}
height={32}
className={cn(className, "rounded object-cover")}
/>
</SpoiledWrapper>
) : (
<div
className={cn(
"flex h-16 w-16 items-center justify-center rounded bg-muted",
className,
)}
>
{getIcon()}
</div>
)}
</>
);
}

View file

@ -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 (
<FileDrawer file={file} downloadProgress={downloadProgress}>
<Card>
<CardContent className="p-4">
<div className="flex items-center gap-4">
<FileAvatar file={file} />
<div className="flex-1">
<h3 className="max-w-40 truncate font-medium">{file.fileName}</h3>
<div className="mb-2 flex items-center justify-between text-sm text-muted-foreground">
<span>
{prettyBytes(file.size)} {file.type}
</span>
<FileStatus file={file} />
</div>
<div
className={cn(
"mb-2 w-full overflow-hidden",
file.downloadStatus === "idle" && "hidden",
)}
>
<FileProgress file={file} downloadProgress={downloadProgress} />
</div>
<div className="flex items-center justify-end">
<FileControl
file={file}
downloadSpeed={downloadSpeed}
isMobile={true}
/>
</div>
</div>
</div>
</CardContent>
</Card>
</FileDrawer>
);
}
function FileAvatar({
file,
loadPreview,
}: {
file: TelegramFile;
loadPreview?: boolean;
}) {
const getIcon = () => {
switch (file.type) {
case "photo":
return <ImageIcon className="h-6 w-6" />;
case "video":
return <VideoIcon className="h-6 w-6" />;
case "audio":
return <FileAudio2Icon className="h-6 w-6" />;
default:
return <FileIcon className="h-6 w-6" />;
}
};
return (
<>
{file.type === "photo" || file.type === "video" ? (
file.thumbnail ? (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
{loadPreview ? (
<PhotoPreview
thumbnail={file.thumbnail}
name={file.fileName}
chatId={file.chatId}
messageId={file.messageId}
/>
) : (
<Image
src={`data:image/jpeg;base64,${file.thumbnail}`}
alt={file.fileName ?? "File thumbnail"}
width={32}
height={32}
className="inline-block h-16 w-16 rounded object-cover"
/>
)}
</SpoiledWrapper>
) : (
<div className="flex h-16 w-16 items-center justify-center rounded bg-muted">
{getIcon()}
</div>
)
) : (
<div className="flex h-16 w-16 items-center justify-center rounded bg-muted">
{getIcon()}
</div>
)}
</>
);
}
function FileDrawer({
file,
downloadProgress,
children,
}: {
file: TelegramFile;
downloadProgress: number;
children: React.ReactNode;
}) {
return (
<Drawer>
<DrawerTrigger asChild>{children}</DrawerTrigger>
<DrawerContent
className="max-h-[96vh] focus:outline-none"
aria-describedby={undefined}
>
<DrawerHeader className="text-center">
<DrawerTitle className="flex flex-col items-center justify-center gap-2">
<FileAvatar file={file} loadPreview={true} />
<span className="max-w-64 truncate">{file.fileName}</span>
</DrawerTitle>
<div className="py-1">
<FileStatus file={file} />
</div>
{file.caption && (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
<DrawerDescription
className="mx-auto mt-2 max-h-48 max-w-md overflow-y-auto text-start"
dangerouslySetInnerHTML={{
__html: file.caption.replaceAll("\n", "<br />"),
}}
></DrawerDescription>
</SpoiledWrapper>
)}
<FileProgress file={file} downloadProgress={downloadProgress} />
</DrawerHeader>
<div className="px-2">
<Separator className="my-2" />
</div>
<div className="space-y-6 p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Type className="h-4 w-4" />
<span>Type</span>
</div>
<Badge variant="secondary" className="text-xs">
{file.type}
</Badge>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HardDrive className="h-4 w-4" />
<span>Size</span>
</div>
<Badge variant="secondary" className="text-xs">
{prettyBytes(file.size)}
</Badge>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>Received At</span>
</div>
<Badge variant="secondary" className="text-xs">
{formatDistanceToNow(new Date(file.date * 1000), {
addSuffix: true,
})}
</Badge>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Download className="h-4 w-4" />
<span>Downloaded Size</span>
</div>
<Badge variant="secondary" className="text-xs">
{prettyBytes(file.downloadedSize)}
</Badge>
</div>
{file.downloadStatus !== "idle" && file.startDate !== 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock10 className="h-4 w-4" />
<span>Download At</span>
</div>
<Badge variant="secondary" className="text-xs">
{formatDistanceToNow(new Date(file.startDate), {
addSuffix: true,
})}
</Badge>
</div>
)}
{file.completionDate && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ClockArrowDown className="h-4 w-4" />
<span>Completion At</span>
</div>
<Badge variant="secondary" className="text-xs">
{formatDistanceToNow(new Date(file.completionDate), {
addSuffix: true,
})}
</Badge>
</div>
)}
</div>
</div>
<DrawerFooter>
<MobileFileControl file={file} />
</DrawerFooter>
</DrawerContent>
</Drawer>
);
}

View file

@ -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) => (
<Tooltip>
<TooltipTrigger asChild>
<Button variant="ghost" size="xs" onClick={onClick}>
<Button
variant={isMobile ? "default" : "ghost"}
size={isMobile ? "icon" : "xs"}
onClick={onClick}
>
{loading ? <Loader2 className="h-4 w-4 animate-spin" /> : icon}
</Button>
</TooltipTrigger>
@ -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<DownloadStatus, ActionButtonProps[]> = {
idle: [
{
onClick: () => start(file.id),
tooltipText: "Start Download",
icon: <ArrowDown className="h-4 w-4" />,
icon: <ArrowDown className={iconSize} />,
loading: starting,
},
],
@ -68,7 +90,7 @@ export default function FileControl({
{
onClick: () => start(file.id),
tooltipText: "Retry",
icon: <ArrowDown className="h-4 w-4" />,
icon: <ArrowDown className={iconSize} />,
loading: starting,
},
],
@ -76,13 +98,13 @@ export default function FileControl({
{
onClick: () => togglePause(file.id),
tooltipText: "Pause",
icon: <Pause className="h-4 w-4" />,
icon: <Pause className={iconSize} />,
loading: togglingPause,
},
{
onClick: () => cancel(file.id),
tooltipText: "Cancel",
icon: <SquareX className="h-4 w-4" />,
icon: <SquareX className={iconSize} />,
loading: cancelling,
},
],
@ -90,13 +112,13 @@ export default function FileControl({
{
onClick: () => togglePause(file.id),
tooltipText: "Resume",
icon: <StepForward className="h-4 w-4" />,
icon: <StepForward className={iconSize} />,
loading: togglingPause,
},
{
onClick: () => cancel(file.id),
tooltipText: "Cancel",
icon: <SquareX className="h-4 w-4" />,
icon: <SquareX className={iconSize} />,
loading: cancelling,
},
],
@ -104,7 +126,7 @@ export default function FileControl({
{
onClick: () => remove(file.id),
tooltipText: "Remove",
icon: <FileX className="h-4 w-4" />,
icon: <FileX className={iconSize} />,
loading: removing,
},
],
@ -113,11 +135,11 @@ export default function FileControl({
const actionButtons = (
<div className="w-full">
<div
className="flex w-full items-center justify-end space-x-2 md:justify-around"
className="flex w-full items-center justify-end space-x-4 md:justify-around md:space-x-2"
onClick={(e) => e.preventDefault()}
>
{statusMapping[file.downloadStatus].map((btnProps, index) => (
<ActionButton key={index} {...btnProps} />
<ActionButton key={index} isMobile={isMobile} {...btnProps} />
))}
</div>
</div>
@ -141,7 +163,7 @@ export default function FileControl({
className="absolute w-full"
>
<span className="text-nowrap text-xs">
{file.downloadStatus === "downloading"
{file.downloadStatus === "downloading" && downloadSpeed
? `${prettyBytes(downloadSpeed, { bits: true })}/s`
: prettyBytes(file.downloadedSize)}
</span>

View file

@ -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 }) {
<div className="flex items-center gap-2 text-sm">
<FileCheck className="h-4 w-4 flex-shrink-0" />
<p
className="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"
onClick={() => 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()}
<Copy className="ml-1 inline-flex h-4 w-4 opacity-0 group-hover:opacity-100" />

View file

@ -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({
</Select>
</div>
<div className="hidden gap-4 md:flex">
<TableColumnFilter
columns={columns}
onColumnConfigChange={onColumnConfigChange}
/>
<TableRowHeightSwitch
rowHeight={rowHeight}
setRowHeightAction={setRowHeight}
/>
</div>
{!isMobile && (
<div className="hidden gap-4 md:flex">
<TableColumnFilter
columns={columns}
onColumnConfigChange={onColumnConfigChange}
/>
<TableRowHeightSwitch
rowHeight={rowHeight}
setRowHeightAction={setRowHeight}
/>
</div>
)}
</div>
);
}

View file

@ -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 (
<div className="flex items-end justify-between gap-2">
<Progress value={progress} className="flex-1 rounded-none md:w-32" />
</div>
);
}

View file

@ -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 = <ImageIcon className="h-4 w-4" />;
break;
case "video":
icon = <VideoIcon className="h-4 w-4" />;
break;
case "audio":
icon = <FileAudioIcon className="h-4 w-4" />;
break;
default:
icon = <FileIcon className="h-4 w-4" />;
}
return (
<div
className={cn(
dynamicClass.content,
"flex items-center justify-center rounded border",
)}
>
{icon}
</div>
);
};
const columnRenders: Record<string, ReactNode> = {
content: (
<div className="flex items-center justify-center gap-2">
{file.type === "photo" || file.type === "video" ? (
file.thumbnail ? (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
<TooltipProvider>
<Tooltip>
<TooltipTrigger>
<PhotoColumnImage
thumbnail={file.thumbnail}
name={file.fileName}
wh={dynamicClass.content}
/>
</TooltipTrigger>
<TooltipContent asChild>
<PhotoPreview
thumbnail={file.thumbnail}
name={file.fileName}
chatId={file.chatId}
messageId={file.messageId}
/>
</TooltipContent>
</Tooltip>
</TooltipProvider>
</SpoiledWrapper>
) : (
getFileIcon(file.type)
)
) : (
getFileIcon(file.type)
)}
<div
className="flex cursor-pointer items-center justify-center gap-2"
onClick={onFileClick}
>
<FileAvatar file={file} className={dynamicClass.content} />
</div>
),
type: (
@ -165,29 +106,14 @@ export default function FileRow({
) : null,
)}
</div>
<div className="w-full">
<FileProgress file={file} downloadProgress={downloadProgress} />
</div>
{downloadProgress > 0 && downloadProgress !== 100 && (
<div className="flex w-full items-end justify-between gap-2">
<Progress
value={downloadProgress}
className="flex-1 rounded-none md:w-32"
/>
</div>
)}
</div>
);
}
const PhotoColumnImage = memo(function PhotoColumnImage({
thumbnail,
name,
wh,
}: {
thumbnail: string;
name: string;
wh: string;
}) {
return (
<Image
src={`data:image/jpeg;base64,${thumbnail}`}
alt={name ?? "File thumbnail"}
width={32}
height={32}
className={cn(wh, "rounded object-cover")}
/>
);
});

View file

@ -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 (
<div className="flex items-center justify-center space-x-2">
<div
className={cn("flex items-center justify-center space-x-2", className)}
>
<AnimatePresence>
{file.transferStatus === "idle" && (
<motion.div

View file

@ -1,5 +1,4 @@
"use client";
import { FileCard } from "./file-card";
import React, { useEffect, useMemo, useRef, useState } from "react";
import { Checkbox } from "@/components/ui/checkbox";
import { Button } from "@/components/ui/button";
@ -13,16 +12,12 @@ import {
import { type Column } from "@/components/table-column-filter";
import { cn } from "@/lib/utils";
import FileNotFount from "@/components/file-not-found";
import useIsMobile from "@/hooks/use-is-mobile";
import useSWRMutation from "swr/mutation";
import { POST } from "@/lib/api";
import FileRow from "@/components/file-row";
import { useVirtualizer } from "@tanstack/react-virtual";
interface FileListProps {
accountId: string;
chatId: string;
}
import { type TelegramFile } from "@/lib/types";
import FileViewer from "@/components/file-viewer";
const COLUMNS: Column[] = [
{
@ -53,23 +48,29 @@ const COLUMNS: Column[] = [
},
];
export function FileList({ accountId, chatId }: FileListProps) {
const isMobile = useIsMobile();
interface FileTableProps {
accountId: string;
chatId: string;
}
export function FileTable({ accountId, chatId }: FileTableProps) {
const [selectedFiles, setSelectedFiles] = useState<Set<number>>(new Set());
const observerTarget = useRef(null);
const tableParentRef = useRef(null);
const [columns, setColumns] = useState<Column[]>(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<TelegramFile | undefined>();
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 (
<div className="space-y-4">
<FileFilters
telegramId={accountId}
chatId={chatId}
filters={filters}
onFiltersChange={handleFilterChange}
columns={columns}
onColumnConfigChange={setColumns}
rowHeight={rowHeight}
setRowHeight={setRowHeight}
/>
<div className="grid grid-cols-1 gap-4">
{files.map((file, index) => (
<FileCard
key={`${file.id}-${file.uniqueId}-${index}`}
file={file}
/>
))}
</div>
<div ref={observerTarget} className="h-4">
{isLoading && (
<div className="flex items-center justify-center">
<LoaderPinwheel
className="h-8 w-8 animate-spin"
style={{ strokeWidth: "0.8px" }}
/>
</div>
)}
</div>
</div>
);
}
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 && (
<FileViewer
open={viewerOpen}
onOpenChange={setViewerOpen}
file={currentViewFile}
onFileChange={setCurrentViewFile}
{...useFilesProps}
/>
)}
<div className="h-[calc(100vh-13rem)] space-y-4 overflow-hidden">
{selectedFiles.size > 0 && (
<div className="flex items-center justify-between rounded-lg bg-muted/50 p-4">
@ -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,

View file

@ -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<typeof useFiles>;
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogPortal>
<DialogOverlay>
<>
<div className="absolute left-0 top-0 z-[100] flex h-16 w-full items-center border-gray-700 px-4 text-white backdrop-blur">
<FileExtra file={file} rowHeight="s" />
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="absolute right-4"
>
<CircleX className="h-6 w-6" />
</Button>
</div>
{file.prev && (
<div
className="fixed bottom-0 left-0 top-0 flex h-full w-20 cursor-pointer items-center justify-center opacity-0 hover:opacity-100"
onClick={() => handleNavigation(-1)}
>
<ChevronLeft className="h-10 w-10 text-white hover:text-accent" />
</div>
)}
{(file.next ?? hasMore) && (
<div
className="fixed bottom-0 right-0 top-0 flex h-full w-20 cursor-pointer items-center justify-center opacity-0 hover:opacity-100"
onClick={() => handleNavigation(1)}
>
<ChevronRight className="h-10 w-10 text-white hover:text-accent" />
</div>
)}
</>
</DialogOverlay>
<DialogPrimitive.Content
data-fileid={file.id}
data-prev={file.prev?.id}
data-next={file.next?.id}
className={cn(
"fixed left-[50%] top-[50%] z-50 translate-x-[-50%] translate-y-[-50%] duration-200 focus-visible:outline-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]",
)}
aria-describedby={undefined}
onInteractOutside={(e) => {
if (e.target instanceof Element) {
if (e.target.getAttribute("data-state")) {
onOpenChange(false);
}
}
e.preventDefault();
}}
>
<div className="relative flex min-h-screen items-center justify-center">
<AnimatePresence
initial={false}
custom={direction}
mode="popLayout"
>
<motion.div
key={file.id}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.5 },
}}
className="mx-auto"
style={{
maxWidth: "calc(100vw - 100px)",
maxHeight: "100vh",
}}
>
<VisuallyHidden>
<DialogTitle>File Viewer</DialogTitle>
</VisuallyHidden>
{file.type === "video" &&
file.downloadStatus === "completed" ? (
<VideoPreview file={file} />
) : (
<PhotoPreview file={file} />
)}
</motion.div>
</AnimatePresence>
</div>
{isLoading && (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<LoaderPinwheel
className="h-8 w-8 animate-spin"
style={{ strokeWidth: "0.8px" }}
/>
</div>
)}
</DialogPrimitive.Content>
</DialogPortal>
</Dialog>
);
}

View file

@ -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 <FileList accountId={accountId} chatId={chatId} />;
} else {
return <FileTable accountId={accountId} chatId={chatId} />;
}
}

View file

@ -119,11 +119,11 @@ export function Header() {
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>
<div className="flex items-center gap-2 text-sm text-muted-foreground max-w-20 overflow-hidden">
<span className="flex-1 text-nowrap">
{`${prettyBytes(accountDownloadSpeed, { bits: true })}/s`}
</span>
<CloudDownloadIcon className="h-4 w-4" />
<CloudDownloadIcon className="flex-shrink-0 h-4 w-4" />
</div>
</TooltipTrigger>
<TooltipContent>

View file

@ -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<HTMLDivElement>;
file: TelegramFile;
onFileClick: () => void;
};
export function FileCard({
index,
className,
style,
ref,
file,
onFileClick,
}: FileCardProps) {
const { downloadProgress } = useFileSpeed(file);
return (
<Card
ref={ref}
data-index={index}
className={cn(
"before:ease-[cubic-bezier(0.4, 0, 0.2, 1)] before:will-change:transform relative before:absolute before:inset-0 before:bottom-0 before:left-0 before:top-auto before:z-10 before:h-2 before:transform before:rounded-bl-xl before:bg-primary before:duration-500 before:content-['']",
downloadProgress > 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}
>
<CardContent className="relative z-20 w-full p-2">
<div className="flex items-center gap-4">
<FileAvatar file={file} className="h-16 w-16 min-w-16" />
<div className="flex-1 overflow-hidden">
<FileExtra file={file} rowHeight="s" />
<div className="flex items-center justify-between">
<div className="flex flex-col justify-start gap-0.5">
<span className="text-xs text-muted-foreground">
{prettyBytes(file.size)} {file.type}
</span>
<FileStatus file={file} className="justify-start" />
</div>
<div
className="flex items-center justify-end"
onClick={(e) => e.stopPropagation()}
>
<FileControl file={file} isMobile={true} />
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}

View file

@ -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<typeof useFiles>;
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 (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open && viewing) {
setViewing(false);
return;
}
onOpenChange(open);
}}
>
<DrawerContent
data-fileid={file.id}
data-prev={file.prev?.id}
data-next={file.next?.id}
className={cn(
"focus:outline-none",
viewing && "rounded-none border-none",
)}
aria-describedby={undefined}
>
<VisuallyHidden>
<DrawerTitle>File Details</DrawerTitle>
</VisuallyHidden>
{isLoading && (
<div className="fixed inset-0 z-[100] flex items-center justify-center">
<LoaderPinwheel
className="h-8 w-8 animate-spin"
style={{ strokeWidth: "0.8px" }}
/>
</div>
)}
<AnimatePresence initial={false} custom={direction} mode="popLayout">
<motion.div
key={file.id}
custom={direction}
variants={slideVariants}
initial="enter"
animate="center"
exit="exit"
transition={{
x: { type: "spring", stiffness: 300, damping: 30 },
opacity: { duration: 0.2 },
}}
style={{
maxWidth: "100vw",
maxHeight: "100vh",
}}
>
{viewing ? (
<div className="relative flex min-h-screen items-center justify-center">
<div className="absolute left-2 top-2 z-10">
<Button
onClick={() => setViewing(false)}
variant="ghost"
size="icon"
>
<Minimize2
className={cn(
"h-8 w-8",
file.type === "video" &&
file.downloadStatus === "completed" &&
"text-white",
)}
/>
</Button>
</div>
{file.type === "video" &&
file.downloadStatus === "completed" ? (
<VideoPreview file={file} />
) : (
<PhotoPreview file={file} className="h-full" />
)}
</div>
) : (
<FileInfo onView={() => setViewing(true)} file={file} />
)}
</motion.div>
</AnimatePresence>
</DrawerContent>
</Drawer>
);
}

View file

@ -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 (
<>
<DrawerHeader className="text-center">
<div className="flex flex-col items-center justify-center gap-1">
<FileAvatar file={file} className="w-[calc(100vw-20rem)]" />
<span className="max-w-64 truncate">{file.fileName}</span>
</div>
<div className="py-1">
<FileStatus file={file} />
</div>
{file.caption && (
<SpoiledWrapper hasSensitiveContent={file.hasSensitiveContent}>
<DrawerDescription
className="mx-auto mt-2 max-h-48 max-w-md overflow-y-auto text-start"
dangerouslySetInnerHTML={{
__html: file.caption.replaceAll("\n", "<br />"),
}}
></DrawerDescription>
</SpoiledWrapper>
)}
{downloadProgress > 0 && downloadProgress !== 100 && (
<div className="flex items-end justify-between gap-2">
<Progress
value={downloadProgress}
className="flex-1 rounded-none md:w-32"
/>
</div>
)}
</DrawerHeader>
<div className="px-2">
<Separator className="my-2" />
</div>
<div className="space-y-6 p-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Type className="h-4 w-4" />
<span>Type</span>
</div>
<Badge variant="secondary" className="text-xs">
{file.type}
</Badge>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<HardDrive className="h-4 w-4" />
<span>Size</span>
</div>
<Badge variant="secondary" className="text-xs">
{prettyBytes(file.size)}
</Badge>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar className="h-4 w-4" />
<span>Received At</span>
</div>
<Badge variant="secondary" className="text-xs">
{formatDistanceToNow(new Date(file.date * 1000), {
addSuffix: true,
})}
</Badge>
</div>
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Download className="h-4 w-4" />
<span>Downloaded Size</span>
</div>
<Badge variant="secondary" className="text-xs">
{prettyBytes(file.downloadedSize)}
</Badge>
</div>
{file.downloadStatus !== "idle" && file.startDate !== 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Clock10 className="h-4 w-4" />
<span>Download At</span>
</div>
<Badge variant="secondary" className="text-xs">
{formatDistanceToNow(new Date(file.startDate), {
addSuffix: true,
})}
</Badge>
</div>
)}
{file.completionDate && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<ClockArrowDown className="h-4 w-4" />
<span>Completion At</span>
</div>
<Badge variant="secondary" className="text-xs">
{formatDistanceToNow(new Date(file.completionDate), {
addSuffix: true,
})}
</Badge>
</div>
)}
</div>
</div>
<DrawerFooter>
{file.downloadStatus === "completed" &&
(file.type === "video" || file.type === "photo") && (
<Button className="w-full" onClick={onView}>
<View className="h-4 w-4" />
<span>View</span>
</Button>
)}
<MobileFileControl file={file} />
</DrawerFooter>
</>
);
}

View file

@ -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 (
<div className="space-y-4">
<FileFilters
telegramId={accountId}
chatId={chatId}
filters={filters}
onFiltersChange={handleFilterChange}
/>
{currentViewFile && (
<FileDrawer
open={isDrawerOpen}
onOpenChange={setIsDrawerOpen}
file={currentViewFile}
onFileChange={setCurrentViewFile}
{...useFilesProps}
/>
)}
<div
style={{
height: `${rowVirtual.getTotalSize()}px`,
width: "100%",
position: "relative",
}}
>
{files.length !== 0 &&
rowVirtual.getVirtualItems().map((virtualRow) => {
const isLoaderRow = virtualRow.index > files.length - 1;
const file = files[virtualRow.index]!;
if (isLoaderRow) {
return (
<div
className="absolute left-0 top-0 flex w-full items-center justify-center"
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
key="loader"
>
{hasMore ? (
<LoaderPinwheel
className="h-8 w-8 animate-spin"
style={{ strokeWidth: "0.8px" }}
/>
) : (
<p className="text-muted-foreground">No more files</p>
)}
</div>
);
}
return (
<FileCard
key={`${file.id}-${file.uniqueId}-${virtualRow.index}`}
index={virtualRow.index}
className={cn("absolute left-0 top-0 flex w-full items-center")}
style={{
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
ref={rowVirtual.measureElement}
file={file}
onFileClick={() => {
setCurrentViewFile(file);
setIsDrawerOpen(true);
}}
{...useFilesProps}
/>
);
})}
</div>
</div>
);
}

View file

@ -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!",
}) => (
<div
className={`flex flex-col items-center justify-center rounded bg-gray-100 p-4 ${className}`}
>
<ImageOff className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-sm text-gray-500">{message}</p>
</div>
);
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<HTMLImageElement>(null);
const [fileStatus, setFileStatus] = useState<{
fileId: number | null;
readyFileIds: number[];
}>({
fileId: null,
readyFileIds: [],
});
const { lastJsonMessage } = useWebsocket();
const { error } = useSWR<void, Error>(
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 = (
<div className="rounded-lg border border-gray-300 bg-white p-2 shadow-lg">
<Image
src={`data:image/jpeg;base64,${thumbnail}`}
alt={name ?? "Photo Thumbnail"}
width={32}
height={32}
className="h-[200px] w-full"
/>
</div>
);
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
if (settings?.needToLoadImages !== "true") {
return ThumbnailImage;
const handleError = () => {
setError(true);
};
if (error) {
return <ImageErrorFallback className="h-full min-h-[200px] w-full" />;
}
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 (
<div className="relative rounded-lg border border-gray-300 bg-white p-2 shadow-lg">
{ThumbnailImage}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 transform">
{error ? (
<CloudAlert className="h-8 w-8" />
) : (
!isReady && <Loader className="h-8 w-8 animate-spin text-white" />
)}
</div>
<div
className={cn("relative", className)}
style={{ maxHeight: `${maxHeight}px` }}
>
<Image
src={src}
placeholder="blur"
unoptimized={true}
blurDataURL={`data:image/jpeg;base64,${file.thumbnail}`}
alt={file.fileName ?? "Photo"}
width={calculatedWidth}
height={calculatedHeight}
className={cn("h-auto max-h-screen w-auto object-contain")}
onError={handleError}
/>
</div>
);
}
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 (
<div className="rounded-lg border border-gray-300 bg-white p-2 shadow-lg">
<div className={cn("relative h-60 w-60", className)}>
<Image
ref={imageRef}
src={url}
src={src}
placeholder="blur"
unoptimized={true}
blurDataURL={`data:image/jpeg;base64,${thumbnail}`}
alt={name ?? "Photo"}
width={32}
height={32}
className="h-[200px] w-full"
onClick={toggleFullScreen}
blurDataURL={`data:image/jpeg;base64,${file.thumbnail}`}
alt={file.fileName ?? "Photo"}
fill={true}
className="h-auto w-auto object-contain"
onError={handleError}
/>
</div>
);

View file

@ -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.
</p>
</div>
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
<div className="flex items-center space-x-2">
<Label htmlFor="need-load-preview-images">
Need Load Preview Images
</Label>
<Checkbox
id="need-load-preview-images"
checked={settings?.needToLoadImages === "true"}
onCheckedChange={(checked) => {
void setSetting("needToLoadImages", String(checked));
}}
/>
</div>
{settings?.needToLoadImages === "true" && (
<div className="space-y-2">
<Label htmlFor="image-load-size">Load Size</Label>
<Select
value={settings.imageLoadSize}
onValueChange={(v) => void setSetting("imageLoadSize", v)}
>
<SelectTrigger id="image-load-size">
<SelectValue placeholder="Select Image Load Size" />
</SelectTrigger>
<SelectContent>
{imageLoadSizeOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
The size of the image to load in the browser.
</p>
</div>
)}
</div>
<div className="flex w-full flex-col space-y-4 rounded-md border p-4 shadow">
<div className="flex items-center space-x-2">
<Label htmlFor="always-hide">Always Hide</Label>

View file

@ -48,7 +48,6 @@ const DrawerContent = React.forwardRef<
)}
{...props}
>
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
{children}
</DrawerPrimitive.Content>
</DrawerPortal>

View file

@ -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<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-primary/20">
<SliderPrimitive.Range className="absolute h-full bg-primary" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border border-primary/50 bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
Slider.displayName = SliderPrimitive.Root.displayName;
export { Slider };

View file

@ -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!",
}) => (
<div
className={`flex flex-col items-center justify-center rounded bg-gray-100 p-4 ${className}`}
>
<VideoOff className="mb-2 h-8 w-8 text-gray-400" />
<p className="text-sm text-gray-500">{message}</p>
</div>
);
const Slider = React.forwardRef<
React.ElementRef<typeof SliderPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SliderPrimitive.Root>
>(({ className, ...props }, ref) => (
<SliderPrimitive.Root
ref={ref}
className={cn(
"relative flex w-full touch-none select-none items-center",
className,
)}
{...props}
>
<SliderPrimitive.Track className="relative h-1.5 w-full grow overflow-hidden rounded-full bg-gray-600/40">
<SliderPrimitive.Range className="absolute h-full bg-white" />
</SliderPrimitive.Track>
<SliderPrimitive.Thumb className="block h-4 w-4 rounded-full border-4 border-white bg-background shadow transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50" />
</SliderPrimitive.Root>
));
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<HTMLDivElement>;
onProgressBarHover: (e: React.MouseEvent) => void;
onProgressBarLeave: () => void;
showPreview: boolean;
previewTime: number;
previewPos: number;
canvasRef: React.RefObject<HTMLCanvasElement>;
}) => {
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 (
<div className="space-y-4">
<div
ref={progressBarRef}
className="relative"
onMouseMove={onProgressBarHover}
onMouseLeave={onProgressBarLeave}
>
<Slider
value={[currentTime]}
max={duration}
step={0.1}
className="w-full cursor-pointer"
onValueChange={(value) => value[0] && onSeek(value[0])}
/>
{showPreview && (
<motion.div
initial={{ opacity: 0, y: -10 }}
animate={{ opacity: 1, y: 0 }}
className="absolute bottom-full mb-4 overflow-hidden bg-black"
style={{ left: `${previewPos}px`, transform: "translateX(-50%)" }}
>
<div className="flex aspect-video w-48 items-center justify-center bg-black">
<canvas
ref={canvasRef}
className="h-full w-full object-contain"
/>
</div>
<div className="bg-black/80 px-2 py-1 text-center text-sm text-white">
{formatTime(previewTime)}
</div>
</motion.div>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={onPlayPause}
>
{isPlaying ? (
<Pause className="h-6 w-6" />
) : (
<Play className="h-6 w-6" />
)}
</Button>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={onMuteToggle}
>
{isMuted ? (
<VolumeX className="h-6 w-6" />
) : (
<Volume2 className="h-6 w-6" />
)}
</Button>
<Slider
value={[volume * 100]}
max={100}
className="w-24"
onValueChange={(value) => onVolumeChange(value[0]! / 100)}
/>
</div>
<div className="text-sm text-white">
{formatTime(currentTime)} / {formatTime(duration)}
</div>
<div className="ml-auto flex items-center gap-2">
<Popover>
<PopoverTrigger asChild>
<Button
variant="ghost"
size="sm"
className="text-white hover:bg-white/20"
>
{playbackRate}x
</Button>
</PopoverTrigger>
<PopoverContent className="w-18 p-0" modal={true} side="top">
<div className="flex flex-col">
{playbackRates.map((rate) => (
<Button
key={rate}
variant="ghost"
className={cn(
"justify-start rounded-none",
rate === playbackRate && "bg-accent",
)}
onClick={() => onPlaybackRateChange(rate)}
>
{rate}x
</Button>
))}
</div>
</PopoverContent>
</Popover>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={onFullscreenToggle}
>
{isFullscreen ? (
<Minimize className="h-6 w-6" />
) : (
<Maximize className="h-6 w-6" />
)}
</Button>
</div>
</div>
</div>
);
};
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 (
<div className="space-y-4" onClick={(e) => e.stopPropagation()}>
<div className="flex items-center justify-between px-4">
<span className="text-sm text-white">{formatTime(currentTime)}</span>
<span className="text-sm text-white">{formatTime(duration)}</span>
</div>
<Slider
value={[currentTime]}
max={duration}
step={0.1}
className="w-full cursor-pointer"
onValueChange={(value) => value[0] && onSeek(value[0])}
/>
<div className="flex items-center justify-center gap-8">
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={onSkipBackward}
>
<RotateCcw className="h-8 w-8" />
</Button>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={onPlayPause}
>
{isPlaying ? (
<Pause className="h-12 w-12" />
) : (
<Play className="h-12 w-12" />
)}
</Button>
<Button
variant="ghost"
size="icon"
className="text-white hover:bg-white/20"
onClick={onSkipForward}
>
<RotateCw className="h-8 w-8" />
</Button>
</div>
</div>
);
};
const VideoPreview = ({
file,
onTimeUpdate,
onVolumeChange,
className,
}: {
file: TelegramFile;
onTimeUpdate?: (time: number) => void;
onVolumeChange?: (volume: number) => void;
className?: string;
}) => {
const videoRef = useRef<HTMLVideoElement>(null);
const previewVideoRef = useRef<HTMLVideoElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const progressBarRef = useRef<HTMLDivElement>(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 <VideoErrorFallback className="h-full min-h-[200px] w-full" />;
}
return (
<motion.div
ref={containerRef}
className={cn(
"group relative w-full overflow-hidden bg-black",
isMobile && "flex h-screen w-screen items-center",
)}
onClick={isMobile ? () => setShowControls((prev) => !prev) : undefined}
onMouseEnter={() => setShowControls(true)}
onMouseLeave={() => setShowControls(false)}
>
<video
ref={videoRef}
autoPlay={isMobile}
onPlay={() => isMobile && !isPlaying && setIsPlaying(true)}
onEnded={handleEnded}
onError={() => setError(true)}
src={url}
playsInline
className={cn("max-h-[calc(100vh-5rem)] w-full", className)}
onTimeUpdate={handleTimeUpdate}
/>
{/* Hidden video for preview */}
{!isMobile && (
<video
ref={previewVideoRef}
src={url}
className="hidden"
preload="auto"
/>
)}
<AnimatePresence>
{showControls && (
<motion.div
id="video-controls"
onTouchStart={(e) => e.stopPropagation()}
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className={cn(
"absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/80 to-transparent p-4",
isMobile && "bg-gray-900 bg-opacity-20 pb-16",
)}
>
{isMobile ? (
<MobileControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
onPlayPause={togglePlay}
onSeek={handleSeek}
onSkipForward={handleSkipForward}
onSkipBackward={handleSkipBackward}
/>
) : (
<DesktopControls
isPlaying={isPlaying}
currentTime={currentTime}
duration={duration}
volume={volume}
isMuted={isMuted}
isFullscreen={isFullscreen}
playbackRate={playbackRate}
onPlayPause={togglePlay}
onVolumeChange={handleVolumeChange}
onMuteToggle={toggleMute}
onFullscreenToggle={toggleFullscreen}
onPlaybackRateChange={handlePlaybackRateChange}
onSeek={handleSeek}
progressBarRef={progressBarRef}
onProgressBarHover={handleProgressBarHover}
onProgressBarLeave={() => setShowPreview(false)}
showPreview={showPreview}
previewTime={previewTime}
previewPos={previewPos}
canvasRef={canvasRef}
/>
)}
</motion.div>
)}
</AnimatePresence>
</motion.div>
);
};
export default VideoPreview;

View file

@ -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),
);

View file

@ -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,
};
}

View file

@ -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<void>;
};
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,
};
}

View file

@ -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]);

View file

@ -65,10 +65,6 @@ export const TelegramChatProvider: React.FC<TelegramChatProviderProps> = ({
return;
}
router.push(`/account/${accountId}/${newChatId}`);
toast({
title: "Chat Selected",
description: `Viewing files from ${chat.name}`,
});
};
return (

View file

@ -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",

View file

@ -64,3 +64,7 @@
@apply bg-background text-foreground;
}
}
.before\:w-progress::before {
width: var(--tw-progress-width);
}

View file

@ -117,6 +117,9 @@ export default {
"5": "hsl(var(--chart-5))",
},
},
width: {
progress: "var(--tw-progress-width)",
},
},
},
plugins: [tailwindcss_animate],