forked from cory/tildefriends
Compare commits
279 Commits
Author | SHA1 | Date | |
---|---|---|---|
fc3dd84122 | |||
9239441d73 | |||
b984811851 | |||
1c52446331 | |||
b6dffa8e66 | |||
315d650d27 | |||
07c121044a | |||
f3169afcf5 | |||
c371fc2a8e | |||
6889e11fd1 | |||
fb73fd0afc | |||
6fcebd7a08 | |||
15ea62a546 | |||
b0cd58f5aa | |||
7fe8f66fd3 | |||
68ca99e9d9 | |||
a2542c658b | |||
eb203c7e62 | |||
6ef466f3ed | |||
5074246462 | |||
73bbcebddb | |||
18128303b6 | |||
c4a2d790a3 | |||
c1ec150696 | |||
f4b856df15 | |||
85b87553dd | |||
5decdf3afa | |||
a4acee4939 | |||
d06aea2831 | |||
ae0a8b0a33 | |||
f0452704a1 | |||
b8b1f1ba80 | |||
caf7478da4 | |||
0e40ba78a4 | |||
d1eac6c9eb | |||
8f5201b2bc | |||
6022001d66 | |||
f018c367ed | |||
48c47f097a | |||
39ac215b5a | |||
7d562ce85c | |||
51b317233a | |||
87ce715011 | |||
ef5afc1e23 | |||
486212f22a | |||
0e8867dd6e | |||
ca28b5ca82 | |||
19e26c1759 | |||
790f6643a4 | |||
2158ad3c0b | |||
d904d8922f | |||
da50792500 | |||
b4629acc48 | |||
0cf4118330 | |||
dd61a6ecc3 | |||
8e6f1284e1 | |||
813d3cd492 | |||
f421606e21 | |||
1ccb9183b4 | |||
7d9b627f37 | |||
3038138909 | |||
2ca08d21e4 | |||
478e96fc5f | |||
e237c7ea1d | |||
bf9ff088fd | |||
e073ebedd1 | |||
10d4ae7dcc | |||
5b8bdbb3e4 | |||
c807e21c6b | |||
cc92d0e316 | |||
09c396d5a3 | |||
bc5bbca951 | |||
ed4faedcd7 | |||
251556ebed | |||
1324afb459 | |||
1119804fc2 | |||
cdf6440197 | |||
8727fe00af | |||
7da7890bb6 | |||
706bd2c51f | |||
acabec940e | |||
470b998b61 | |||
80fad05f23 | |||
07a912fb9a | |||
e9d83262c4 | |||
74323c22f9 | |||
2614e89b1b | |||
e092fe1399 | |||
9cbe895cb8 | |||
b0b0f74e83 | |||
d9eaa92c37 | |||
566d07117e | |||
2bffdb1168 | |||
1359b48c9f | |||
a69fb5eeac | |||
38e313350e | |||
5052dc04f2 | |||
9ef3a3aca0 | |||
7b91a2ec37 | |||
2926f855a1 | |||
639419db60 | |||
54747c127c | |||
791c3dd787 | |||
b00d75ab7c | |||
956ea0df56 | |||
30014040e7 | |||
ab055c3394 | |||
1e37eeea05 | |||
84aec0278d | |||
06642f58c5 | |||
e6d44b32f4 | |||
1f3f6e2b92 | |||
8f2d3e3bcd | |||
2df2fc5792 | |||
20b0337e0a | |||
e86b9dae48 | |||
71de897419 | |||
3edfaf9137 | |||
19c1784864 | |||
0d9fac7363 | |||
2fb91fccc0 | |||
24e1ab12ab | |||
10ea885d8d | |||
ec65faa12d | |||
53692a1ea8 | |||
ebef51b4ea | |||
a94d6f9271 | |||
3d2c88c201 | |||
bdeee7fc0e | |||
33a037e0ea | |||
2dc2d9ebf6 | |||
9748f0ed8b | |||
d6be2f7d54 | |||
63615747a7 | |||
fbb657a85c | |||
bdac0c7879 | |||
54dde76a8a | |||
2bbe22bc7a | |||
ad8532f7ac | |||
602941104e | |||
d38b41687c | |||
08125cd1e8 | |||
2ce2097a3f | |||
a5da17e1b1 | |||
2b0962f087 | |||
37173cce4c | |||
37edbd9824 | |||
a32bb02223 | |||
2ab1b84432 | |||
52ae19220c | |||
10bfa65a4e | |||
2a3b1a1e33 | |||
f74f4f6da9 | |||
12a8b7a058 | |||
400f07660f | |||
d532795b7f | |||
6064ed6a3a | |||
2c1a43df2e | |||
bf72782c9f | |||
63dcab30c3 | |||
50e48af7c4 | |||
9127a18ff0 | |||
61ff466908 | |||
1c10768aa4 | |||
992b123853 | |||
f736756b20 | |||
28d73f5b37 | |||
262b0e5e52 | |||
1e3807bcb9 | |||
2ed3295f77 | |||
8c9d687d50 | |||
b8b694864e | |||
961109635b | |||
86bc46a11e | |||
a6a6fe75ec | |||
f55f863867 | |||
4ce988d00b | |||
1548a8a852 | |||
a9551b057b | |||
88c7d91858 | |||
53cb80ebf7 | |||
1f67343d75 | |||
4bea8bb6ba | |||
8e1461b3f1 | |||
90b513d070 | |||
8a2d3d4669 | |||
1741403206 | |||
980db880cc | |||
507a62539d | |||
6b5d73ed5c | |||
1f77df7a90 | |||
fa87462405 | |||
a5f9f927e6 | |||
b35d74ce36 | |||
ac60be14a5 | |||
beda047eb0 | |||
f6742bebf3 | |||
7f334ad783 | |||
ffda896308 | |||
b2fbe9dfac | |||
6d6c41bffa | |||
e04d137af5 | |||
ec52e62908 | |||
6104af0d70 | |||
0ca05e297d | |||
e0dcec074c | |||
a8cecb5c64 | |||
582ee0e4d7 | |||
0ba54c2b7b | |||
3c288f7f68 | |||
c692b1b1f8 | |||
7091b6e6a5 | |||
48cd08e095 | |||
ef7f9db9c4 | |||
0092f24fb9 | |||
f9db1a7acf | |||
da75ad9337 | |||
7318ddd70e | |||
ab75ec07f8 | |||
0a6b842179 | |||
5d5ff121f9 | |||
adefa76dfd | |||
2420869e7f | |||
f841ca4399 | |||
433db904cd | |||
c067623740 | |||
dab7050899 | |||
77df158178 | |||
0af1bcf110 | |||
e05302ac99 | |||
ce6cc82d64 | |||
85a2bc3f0f | |||
3285d93576 | |||
0f11f497ed | |||
45a5202456 | |||
ce0b4de5a1 | |||
134b2556ad | |||
67d34bf70e | |||
73863f9418 | |||
0cbc1a650b | |||
9248dfd97e | |||
b8f54f324f | |||
3269c7ca45 | |||
8a1b4cceec | |||
7cd925feca | |||
f6ae15c4dc | |||
6ed057089b | |||
a5ba014736 | |||
4d4cc92150 | |||
3b00b31e87 | |||
3c687dc780 | |||
987b2d539a | |||
80a1e94da4 | |||
69253432b8 | |||
53e4f4341c | |||
6ff33191bb | |||
513eb88a53 | |||
3506d9dec1 | |||
c09e043812 | |||
4c01f23ee8 | |||
ff06e91ac8 | |||
8ed359327c | |||
a66a70324d | |||
67fbbd4a8d | |||
235fc9b8f9 | |||
f257cccded | |||
5342ddb2bd | |||
7cba1b21ad | |||
120ed36552 | |||
a9f6593979 | |||
ca6d042ed6 | |||
ae4c2aef69 | |||
ed1c85288c | |||
71151a511d | |||
7f35f01b88 | |||
1d13c25ded | |||
09ddfffa6b | |||
d9aee6d05f | |||
94d7d2e3e0 |
@ -2,8 +2,10 @@ FROM bitnami/minideb:bullseye AS build
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
build-essential \
|
||||
libssl-dev
|
||||
gcc \
|
||||
libc6-dev \
|
||||
libssl-dev \
|
||||
make
|
||||
|
||||
COPY . /app
|
||||
RUN make -C /app -j $(nproc) release
|
||||
|
288
Makefile
288
Makefile
@ -3,9 +3,13 @@
|
||||
MAKEFLAGS += --warn-undefined-variables
|
||||
MAKEFLAGS += --no-builtin-rules
|
||||
|
||||
VERSION_CODE := 9
|
||||
VERSION_NUMBER := 0.0.9
|
||||
VERSION_NAME := Failure is the only opportunity to begin again.
|
||||
|
||||
PROJECT = tildefriends
|
||||
BUILD_DIR ?= out
|
||||
BUILD_TYPES := debug release windebug winrelease
|
||||
BUILD_TYPES := debug release windebug winrelease androiddebug androidrelease androiddebug-x86_64 androidrelease-x86_64
|
||||
UNAME_M := $(shell uname -m)
|
||||
|
||||
CFLAGS += \
|
||||
@ -16,17 +20,76 @@ CFLAGS += \
|
||||
-MMD \
|
||||
-ffunction-sections \
|
||||
-fdata-sections \
|
||||
-fno-omit-frame-pointer \
|
||||
-fno-exceptions \
|
||||
-g
|
||||
LDFLAGS += -Wl,-gc-sections
|
||||
LDFLAGS += -Wl,--gc-sections
|
||||
|
||||
debug windebug: CFLAGS += -Og
|
||||
debug release: LDFLAGS += -rdynamic
|
||||
release winrelease: CFLAGS += -DNDEBUG -O3
|
||||
windebug winrelease: CC = i686-w64-mingw32-gcc-win32
|
||||
ANDROID_SDK ?= ~/Android/Sdk
|
||||
ANDROID_BUILD_TOOLS := $(ANDROID_SDK)/build-tools/33.0.1
|
||||
ANDROID_PLATFORM := $(ANDROID_SDK)/platforms/android-33
|
||||
ANDROID_NDK ?= $(ANDROID_SDK)/ndk/23.1.7779620
|
||||
ANDROID_NDK_API_VERSION := 31
|
||||
ANDROID_MIN_SDK_VERSION := 26
|
||||
|
||||
ANDROID_ARM64_TARGETS := \
|
||||
out/androiddebug/tildefriends \
|
||||
out/androidrelease/tildefriends
|
||||
ANDROID_X86_64_TARGETS := \
|
||||
out/androiddebug-x86_64/tildefriends \
|
||||
out/androidrelease-x86_64/tildefriends
|
||||
ANDROID_TARGETS := \
|
||||
$(ANDROID_X86_64_TARGETS) \
|
||||
$(ANDROID_ARM64_TARGETS)
|
||||
|
||||
DEBUG_TARGETS := \
|
||||
out/debug/tildefriends \
|
||||
out/windebug/tildefriends \
|
||||
out/androiddebug/tildefriends \
|
||||
out/androiddebug-x86_64/tildefriends
|
||||
RELEASE_TARGETS := \
|
||||
out/release/tildefriends \
|
||||
out/winrelease/tildefriends \
|
||||
out/androidrelease/tildefriends \
|
||||
out/androidrelease-x86_64/tildefriends
|
||||
ANDROID_RELEASE_TARGETS := $(filter-out $(DEBUG_TARGETS),$(ANDROID_TARGETS))
|
||||
NONANDROID_RELEASE_TARGETS := $(filter-out $(ANDROID_ARM64_TARGETS),$(RELEASE_TARGETS))
|
||||
NONANDROID_TARGETS := $(filter-out $(ANDROID_TARGETS),$(DEBUG_TARGETS) $(RELEASE_TARGETS))
|
||||
|
||||
$(NONANDROID_TARGETS): CFLAGS += -fno-omit-frame-pointer
|
||||
$(NONANDROID_TARGETS): LDFLAGS += -rdynamic
|
||||
$(ANDROID_TARGETS): CFLAGS += \
|
||||
--sysroot $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/sysroot \
|
||||
-fPIC \
|
||||
-fdebug-compilation-dir . \
|
||||
-fomit-frame-pointer \
|
||||
-fno-asynchronous-unwind-tables
|
||||
$(ANDROID_TARGETS): LDFLAGS += --sysroot $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/sysroot -fPIC
|
||||
$(DEBUG_TARGETS): CFLAGS += -DDEBUG -Og
|
||||
$(RELEASE_TARGETS): CFLAGS += -DNDEBUG
|
||||
$(NONANDROID_RELEASE_TARGETS): CFLAGS += -O3
|
||||
$(ANDROID_RELEASE_TARGETS): CFLAGS += -Os
|
||||
windebug winrelease: CC = x86_64-w64-mingw32-gcc-win32
|
||||
windebug winrelease: AS = $(CC)
|
||||
windebug winrelease: CFLAGS += -D_WIN32_WINNT=0x0A00 -DWINVER=0x0A00 -DNTDDI_VERSION=NTDDI_WIN10
|
||||
windebug winrelease: LDFLAGS += -static
|
||||
windebug winrelease: CFLAGS += \
|
||||
-D_WIN32_WINNT=0x0A00 \
|
||||
-DWINVER=0x0A00 \
|
||||
-DNTDDI_VERSION=NTDDI_WIN10 \
|
||||
-Ideps/openssl/mingw64/include
|
||||
windebug winrelease: LDFLAGS += \
|
||||
-static \
|
||||
-lm \
|
||||
-Ldeps/openssl/mingw64/lib
|
||||
$(ANDROID_X86_64_TARGETS): ANDROID_NDK_TARGET_TRIPLE := x86_64-linux-android
|
||||
$(ANDROID_ARM64_TARGETS): ANDROID_NDK_TARGET_TRIPLE := aarch64-linux-android
|
||||
$(ANDROID_TARGETS): CC = $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/clang
|
||||
$(ANDROID_TARGETS): AS = $(CC)
|
||||
$(ANDROID_TARGETS): CFLAGS += \
|
||||
-target $(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_NDK_API_VERSION) \
|
||||
-Wno-unknown-warning-option
|
||||
$(ANDROID_ARM64_TARGETS): CFLAGS += -Ideps/openssl/android/arm64-v8a/usr/local/include
|
||||
$(ANDROID_ARM64_TARGETS): LDFLAGS += -Ldeps/openssl/android/arm64-v8a/usr/local/lib
|
||||
$(ANDROID_X86_64_TARGETS): CFLAGS += -Ideps/openssl/android/x86_64/usr/local/include
|
||||
$(ANDROID_X86_64_TARGETS): LDFLAGS += -Ldeps/openssl/android/x86_64/usr/local/lib
|
||||
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common
|
||||
@ -36,7 +99,9 @@ endif
|
||||
get_objs = \
|
||||
$(foreach build_type,$(BUILD_TYPES),$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)))))) \
|
||||
$(foreach build_type,debug release,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix))))) \
|
||||
$(foreach build_type,windebug winrelease,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_win)))))
|
||||
$(foreach build_type,windebug winrelease,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_win))))) \
|
||||
$(foreach build_type,androiddebug androidrelease androiddebug-x86_64 androidrelease-x86_64,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_android))))) \
|
||||
$(foreach build_type,androiddebug androidrelease androiddebug-x86_64 androidrelease-x86_64,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix)))))
|
||||
|
||||
APP_SOURCES := $(wildcard src/*.c)
|
||||
APP_OBJS := $(call get_objs,APP_SOURCES)
|
||||
@ -47,17 +112,16 @@ $(APP_OBJS): CFLAGS += \
|
||||
-Ideps/libsodium \
|
||||
-Ideps/libsodium/src/libsodium/include \
|
||||
-Ideps/libuv/include \
|
||||
-Ideps/zlib \
|
||||
-Ideps/zlib/contrib/minizip \
|
||||
-Ideps/picohttpparser \
|
||||
-Ideps/quickjs \
|
||||
-Ideps/sqlite \
|
||||
-Ideps/valgrind \
|
||||
-Ideps/xopt \
|
||||
-Wdouble-promotion \
|
||||
-Werror
|
||||
|
||||
BASE64C_SOURCES := deps/base64c/src/base64c.c
|
||||
BASE64C_OBJS := $(call get_objs,BASE64C_SOURCES)
|
||||
$(BASE64C_OBJS): CFLAGS += \
|
||||
-Wno-sign-compare
|
||||
|
||||
BLOWFISH_SOURCES := \
|
||||
deps/crypt_blowfish/crypt_blowfish.c \
|
||||
deps/crypt_blowfish/crypt_gensalt.c \
|
||||
@ -82,13 +146,10 @@ UV_SOURCES_unix := \
|
||||
deps/libuv/src/unix/async.c \
|
||||
deps/libuv/src/unix/core.c \
|
||||
deps/libuv/src/unix/dl.c \
|
||||
deps/libuv/src/unix/epoll.c \
|
||||
deps/libuv/src/unix/fs.c \
|
||||
deps/libuv/src/unix/getaddrinfo.c \
|
||||
deps/libuv/src/unix/getnameinfo.c \
|
||||
deps/libuv/src/unix/linux-core.c \
|
||||
deps/libuv/src/unix/linux-inotify.c \
|
||||
deps/libuv/src/unix/linux-syscalls.c \
|
||||
deps/libuv/src/unix/linux.c \
|
||||
deps/libuv/src/unix/loop-watcher.c \
|
||||
deps/libuv/src/unix/loop.c \
|
||||
deps/libuv/src/unix/pipe.c \
|
||||
@ -105,6 +166,8 @@ UV_SOURCES_unix := \
|
||||
deps/libuv/src/unix/thread.c \
|
||||
deps/libuv/src/unix/tty.c \
|
||||
deps/libuv/src/unix/udp.c
|
||||
UV_SOURCES_android := \
|
||||
deps/libuv/src/unix/random-getentropy.c
|
||||
UV_SOURCES_win := \
|
||||
deps/libuv/src/win/async.c \
|
||||
deps/libuv/src/win/core.c \
|
||||
@ -135,12 +198,13 @@ UV_OBJS := $(call get_objs,UV_SOURCES)
|
||||
$(UV_OBJS): CFLAGS += \
|
||||
-Ideps/libuv/include \
|
||||
-Ideps/libuv/src \
|
||||
-Wno-unused-but-set-variable \
|
||||
-Wno-incompatible-pointer-types \
|
||||
-Wno-sign-compare \
|
||||
-Wno-unused-variable \
|
||||
-Wno-dangling-pointer \
|
||||
-Wno-incompatible-pointer-types \
|
||||
-Wno-maybe-uninitialized \
|
||||
-Wno-sign-compare \
|
||||
-Wno-unused-but-set-variable \
|
||||
-Wno-unused-result \
|
||||
-Wno-unused-variable \
|
||||
-D_GNU_SOURCE
|
||||
|
||||
SODIUM_SOURCES := \
|
||||
@ -181,8 +245,10 @@ SODIUM_SOURCES := \
|
||||
deps/libsodium/src/libsodium/randombytes/randombytes.c \
|
||||
deps/libsodium/src/libsodium/randombytes/sysrandom/randombytes_sysrandom.c \
|
||||
deps/libsodium/src/libsodium/sodium/core.c \
|
||||
deps/libsodium/src/libsodium/sodium/codecs.c \
|
||||
deps/libsodium/src/libsodium/sodium/runtime.c \
|
||||
deps/libsodium/src/libsodium/sodium/utils.c
|
||||
deps/libsodium/src/libsodium/sodium/utils.c \
|
||||
deps/libsodium/src/libsodium/sodium/version.c
|
||||
SODIUM_OBJS := $(call get_objs,SODIUM_SOURCES)
|
||||
$(SODIUM_OBJS): CFLAGS += \
|
||||
-DCONFIGURED=1 \
|
||||
@ -191,28 +257,44 @@ $(SODIUM_OBJS): CFLAGS += \
|
||||
-Wno-unused-variable \
|
||||
-Wno-type-limits \
|
||||
-Wno-unknown-pragmas \
|
||||
-Ideps/libsodium/builds/msvc \
|
||||
-Ideps/libsodium/src/libsodium/include/sodium
|
||||
|
||||
SQLITE_SOURCES := deps/sqlite/sqlite3.c
|
||||
SQLITE_OBJS := $(call get_objs,SQLITE_SOURCES)
|
||||
$(SQLITE_OBJS): CFLAGS += \
|
||||
-DSQLITE_DBCONFIG_DEFAULT_DEFENSIVE \
|
||||
-DSQLITE_DEFAULT_MEMSTATUS=0 \
|
||||
-DSQLITE_DQS=0 \
|
||||
-DSQLITE_ENABLE_MEMSYS5 \
|
||||
-DSQLITE_ENABLE_FTS5 \
|
||||
-DSQLITE_ENABLE_JSON1 \
|
||||
-DSQLITE_MAX_LENGTH=5242880 \
|
||||
-DSQLITE_MAX_SQL_LENGTH=100000 \
|
||||
-DSQLITE_MAX_COLUMN=100 \
|
||||
-DSQLITE_MAX_EXPR_DEPTH=40 \
|
||||
-DSQLITE_MAX_COMPOUND_SELECT=300 \
|
||||
-DSQLITE_MAX_VDBE_OP=25000 \
|
||||
-DSQLITE_MAX_FUNCTION_ARG=8 \
|
||||
-DSQLITE_LIKE_DOESNT_MATCH_BLOBS \
|
||||
-DSQLITE_MAX_ATTACHED=0 \
|
||||
-DSQLITE_MAX_COLUMN=100 \
|
||||
-DSQLITE_MAX_COMPOUND_SELECT=300 \
|
||||
-DSQLITE_MAX_EXPR_DEPTH=40 \
|
||||
-DSQLITE_MAX_FUNCTION_ARG=8 \
|
||||
-DSQLITE_MAX_LENGTH=5242880 \
|
||||
-DSQLITE_MAX_LIKE_PATTERN_LENGTH=50 \
|
||||
-DSQLITE_MAX_VARIABLE_NUMBER=100 \
|
||||
-DSQLITE_MAX_SQL_LENGTH=100000 \
|
||||
-DSQLITE_MAX_TRIGGER_DEPTH=10 \
|
||||
-DSQLITE_MAX_VARIABLE_NUMBER=100 \
|
||||
-DSQLITE_MAX_VDBE_OP=25000 \
|
||||
-DSQLITE_OMIT_DEPRECATED \
|
||||
-DSQLITE_OMIT_DESERIALIZE \
|
||||
-DSQLITE_OMIT_LOAD_EXTENSION \
|
||||
-DSQLITE_OMIT_TCL_VARIABLE \
|
||||
-DSQLITE_PRAGMA_DEFAULT_WAL_SYNCHRONOUS=1 \
|
||||
-DSQLITE_SECURE_DELETE \
|
||||
-DSQLITE_THREADSAFE=0 \
|
||||
-DSQLITE_UNTESTABLE \
|
||||
-DSQLITE_USE_ALLOCA \
|
||||
-DHAVE_ISNAN \
|
||||
-Wno-implicit-fallthrough \
|
||||
-Wno-unused-but-set-variable
|
||||
-Wno-unused-but-set-variable \
|
||||
-Wno-unused-function \
|
||||
-Wno-unused-variable
|
||||
|
||||
XOPT_SOURCES := deps/xopt/xopt.c
|
||||
XOPT_OBJS := $(call get_objs,XOPT_SOURCES)
|
||||
@ -222,25 +304,27 @@ $(filter $(BUILD_DIR)/win%,$(XOPT_OBJS)): CFLAGS += \
|
||||
-DHAVE_VASNPRINTF \
|
||||
-DHAVE_VASPRINTF \
|
||||
-Dvsnprintf=rpl_vsnprintf
|
||||
$(XOPT_OBJS): CFLAGS += \
|
||||
-Wno-implicit-const-int-float-conversion
|
||||
|
||||
QUICKJS_SOURCES := \
|
||||
deps/quickjs/cutils.c \
|
||||
deps/quickjs/libbf.c \
|
||||
deps/quickjs/libregexp.c \
|
||||
deps/quickjs/libunicode.c \
|
||||
deps/quickjs/quickjs-libc.c \
|
||||
deps/quickjs/quickjs.c
|
||||
QUICKJS_OBJS := $(call get_objs,QUICKJS_SOURCES)
|
||||
$(QUICKJS_OBJS): CFLAGS += \
|
||||
-DCONFIG_VERSION=\"$(shell cat deps/quickjs/VERSION)\" \
|
||||
-DCONFIG_BIGNUM \
|
||||
-DDUMP_LEAKS \
|
||||
-D_GNU_SOURCE \
|
||||
-Wno-sign-compare \
|
||||
-Wno-enum-conversion \
|
||||
-Wno-implicit-const-int-float-conversion \
|
||||
-Wno-implicit-fallthrough \
|
||||
-Wno-unused-variable \
|
||||
-Wno-sign-compare \
|
||||
-Wno-unused-but-set-variable \
|
||||
-Wno-enum-conversion
|
||||
-Wno-unused-variable
|
||||
$(NONANDROID_TARGETS): CFLAGS += -DDUMP_LEAKS
|
||||
|
||||
LIBBACKTRACE_SOURCES := \
|
||||
deps/libbacktrace/atomic.c \
|
||||
@ -269,6 +353,24 @@ $(LIBBACKTRACE_OBJS): CFLAGS += \
|
||||
-Wno-unused-function \
|
||||
-DBACKTRACE_ELF_SIZE=64
|
||||
|
||||
PICOHTTPPARSER_SOURCES := \
|
||||
deps/picohttpparser/picohttpparser.c
|
||||
PICOHTTPPARSER_OBJS := $(call get_objs,PICOHTTPPARSER_SOURCES)
|
||||
|
||||
MINIUNZIP_SOURCES := \
|
||||
deps/zlib/contrib/minizip/unzip.c \
|
||||
deps/zlib/contrib/minizip/ioapi.c \
|
||||
deps/zlib/adler32.c \
|
||||
deps/zlib/crc32.c \
|
||||
deps/zlib/inffast.c \
|
||||
deps/zlib/inflate.c \
|
||||
deps/zlib/inftrees.c \
|
||||
deps/zlib/zutil.c
|
||||
MINIUNZIP_OBJS := $(call get_objs,MINIUNZIP_SOURCES)
|
||||
$(MINIUNZIP_OBJS): CFLAGS += \
|
||||
-Ideps/zlib \
|
||||
-Wno-maybe-uninitialized
|
||||
|
||||
LDFLAGS += \
|
||||
-pthread \
|
||||
-lm
|
||||
@ -277,22 +379,35 @@ debug release: LDFLAGS += \
|
||||
-lssl \
|
||||
-lcrypto
|
||||
windebug winrelease: LDFLAGS += \
|
||||
-lwsock32 \
|
||||
-lws2_32 \
|
||||
-lkernel32 \
|
||||
-lssl \
|
||||
-lcrypto \
|
||||
-lcrypt32 \
|
||||
-ldbghelp \
|
||||
-liphlpapi \
|
||||
-luserenv
|
||||
-lkernel32 \
|
||||
-lole32 \
|
||||
-luserenv \
|
||||
-luuid \
|
||||
-lws2_32 \
|
||||
-lwsock32
|
||||
$(ANDROID_TARGETS): LDFLAGS += \
|
||||
-target $(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_NDK_API_VERSION) \
|
||||
-ldl \
|
||||
-llog \
|
||||
-lssl \
|
||||
-lcrypto
|
||||
|
||||
unix: debug release
|
||||
win: windebug winrelease
|
||||
all: $(BUILD_TYPES)
|
||||
all: $(BUILD_TYPES) out/TildeFriends-debug.apk out/TildeFriends-release.apk
|
||||
.PHONY: all win unix
|
||||
|
||||
ALL_APP_OBJS := \
|
||||
$(APP_OBJS) \
|
||||
$(BASE64C_OBJS) \
|
||||
$(BLOWFISH_OBJS) \
|
||||
$(LIBBACKTRACE_OBJS) \
|
||||
$(MINIUNZIP_OBJS) \
|
||||
$(PICOHTTPPARSER_OBJS) \
|
||||
$(QUICKJS_OBJS) \
|
||||
$(SODIUM_OBJS) \
|
||||
$(SQLITE_OBJS) \
|
||||
@ -308,7 +423,7 @@ $(1): $(BUILD_DIR)/$(1)/$(PROJECT)$(if $(filter win%,$(1)),.exe)
|
||||
|
||||
$(BUILD_DIR)/$(1)/$(PROJECT)$(if $(filter win%,$(1)),.exe): $(filter $(BUILD_DIR)/$(1)/%,$(ALL_APP_OBJS))
|
||||
@echo [link] $$@
|
||||
@$$(CC) -o $$@ $$^ $$(LDFLAGS)
|
||||
@$$(CC) -o $$@ -Wl,-Map,$$@.map $$^ $$(LDFLAGS)
|
||||
|
||||
$(BUILD_DIR)/$(1)/%.o: %.c
|
||||
@mkdir -p $$(dir $$@)
|
||||
@ -323,6 +438,89 @@ endef
|
||||
|
||||
$(foreach build_type,$(BUILD_TYPES),$(eval $(call build_rules,$(build_type))))
|
||||
|
||||
src/version.h : $(firstword $(MAKEFILE_LIST))
|
||||
@echo [version] $@
|
||||
@echo "#define VERSION_NUMBER \"$(VERSION_NUMBER)\"\n#define VERSION_NAME \"$(VERSION_NAME)\"\n" > $@
|
||||
|
||||
src/android/AndroidManifest.xml : $(firstword $(MAKEFILE_LIST))
|
||||
@echo [android_version] $@
|
||||
@sed -i \
|
||||
-e 's/versionCode=".*"/versionCode="$(VERSION_CODE)"/' \
|
||||
-e 's/versionName=".*"/versionName="$(VERSION_NUMBER)"/' \
|
||||
-e 's/android:minSdkVersion=".*"/android:minSdkVersion="$(ANDROID_MIN_SDK_VERSION)"/' \
|
||||
$@
|
||||
|
||||
# Android support.
|
||||
out/res/layout_activity_main.xml.flat: src/android/res/layout/activity_main.xml
|
||||
@mkdir -p $(dir $@)
|
||||
@echo [aapt2] $@
|
||||
@$(ANDROID_BUILD_TOOLS)/aapt2 compile -o out/res/ src/android/res/layout/activity_main.xml
|
||||
|
||||
out/res/drawable_icon.xml.flat: src/android/res/drawable/icon.xml
|
||||
@mkdir -p $(dir $@)
|
||||
@echo [aapt2] $@
|
||||
@$(ANDROID_BUILD_TOOLS)/aapt2 compile -o out/res/ src/android/res/drawable/icon.xml
|
||||
|
||||
out/apk/res.apk out/gen/com/unprompted/tildefriends/R.java: out/res/layout_activity_main.xml.flat out/res/drawable_icon.xml.flat src/android/AndroidManifest.xml
|
||||
@mkdir -p $(dir $@)
|
||||
@$(ANDROID_BUILD_TOOLS)/aapt2 link -I $(ANDROID_PLATFORM)/android.jar out/res/layout_activity_main.xml.flat out/res/drawable_icon.xml.flat --manifest src/android/AndroidManifest.xml -o out/apk/res.apk --java out/gen/
|
||||
|
||||
JAVA_FILES := out/gen/com/unprompted/tildefriends/R.java $(wildcard src/android/com/unprompted/tildefriends/*.java)
|
||||
CLASS_FILES := $(foreach src,$(JAVA_FILES),out/classes/com/unprompted/tildefriends/$(notdir $(src:.java=.class)))
|
||||
|
||||
$(CLASS_FILES) &: $(JAVA_FILES)
|
||||
@echo [javac] $(CLASS_FILES)
|
||||
@javac --release 8 -Xlint:deprecation -classpath $(ANDROID_PLATFORM)/android.jar -d out/classes $(JAVA_FILES)
|
||||
|
||||
out/apk/classes.dex: $(CLASS_FILES)
|
||||
@mkdir -p $(dir $@)
|
||||
@echo [d8] $@
|
||||
@$(ANDROID_BUILD_TOOLS)/d8 --$(BUILD_TYPE) --lib $(ANDROID_PLATFORM)/android.jar --output $(dir $@) out/classes/com/unprompted/tildefriends/*.class
|
||||
|
||||
PACKAGE_DIRS := \
|
||||
apps/ \
|
||||
core/ \
|
||||
deps/codemirror/ \
|
||||
deps/lit/ \
|
||||
deps/split/ \
|
||||
deps/smoothie/
|
||||
|
||||
RAW_FILES := $(shell find $(PACKAGE_DIRS) -type f)
|
||||
|
||||
out/apk/TildeFriends-debug.unsigned.apk: BUILD_TYPE := debug
|
||||
out/apk/TildeFriends-release.unsigned.apk: BUILD_TYPE := release
|
||||
|
||||
out/apk/TildeFriends-debug.unsigned.apk: out/apk/classes.dex out/androiddebug/tildefriends out/androiddebug-x86_64/tildefriends $(RAW_FILES) out/apk/res.apk
|
||||
out/apk/TildeFriends-release.unsigned.apk: out/apk/classes.dex out/androidrelease/tildefriends out/androidrelease-x86_64/tildefriends $(RAW_FILES) out/apk/res.apk
|
||||
|
||||
out/%.unsigned.apk:
|
||||
@mkdir -p $(dir $@) out/apk$(BUILD_TYPE)/bin/aarch64/ out/apk$(BUILD_TYPE)/bin/x86_64/
|
||||
@echo [aapt] $@
|
||||
@cp out/android$(BUILD_TYPE)/tildefriends out/apk$(BUILD_TYPE)/bin/aarch64/
|
||||
@cp out/android$(BUILD_TYPE)-x86_64/tildefriends out/apk$(BUILD_TYPE)/bin/x86_64/
|
||||
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk$(BUILD_TYPE)/bin/aarch64/tildefriends
|
||||
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk$(BUILD_TYPE)/bin/x86_64/tildefriends
|
||||
@cp out/apk/res.apk $@
|
||||
@cp out/apk/classes.dex out/apk$(BUILD_TYPE)/
|
||||
@cd out/apk$(BUILD_TYPE) && zip -u ../../$@ -q -9 -r . && cd ../../
|
||||
@zip -u $@ -q -9 -x '*.map' -r $(PACKAGE_DIRS) $(RAW_FILES)
|
||||
|
||||
out/%.apk: out/apk/%.unsigned.apk
|
||||
@echo [apksigner] $(notdir $@)
|
||||
@$(ANDROID_BUILD_TOOLS)/apksigner sign --ks keystore.jks --ks-key-alias androidKey --ks-pass pass:android --key-pass pass:android --out $@ $<
|
||||
|
||||
apk: out/TildeFriends-release.apk
|
||||
.PHONY: apk
|
||||
|
||||
apkgo: out/TildeFriends-release.apk
|
||||
@adb install $<
|
||||
@adb shell am start com.unprompted.tildefriends/.MainActivity
|
||||
.PHONY: apkgo
|
||||
|
||||
apklog:
|
||||
@adb logcat *:S tildefriends
|
||||
.PHONY: apklog
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
.PHONY: clean
|
||||
|
@ -28,7 +28,7 @@ privileges. Further administration can be done at
|
||||
<http://localhost:12345/~core/admin/`>.
|
||||
|
||||
## Documentation
|
||||
There are the very beginnings of developer documentation in `apps/cory/docs/`
|
||||
There are the very beginnings of developer documentation in `apps/docs/`
|
||||
that can be read in-place or at <http://localhost:12345/~core/docs/>.
|
||||
|
||||
## License
|
||||
|
4
apps/admin.json
Normal file
4
apps/admin.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "🎛"
|
||||
}
|
4
apps/api.json
Normal file
4
apps/api.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "📜"
|
||||
}
|
10
apps/api/app.js
Normal file
10
apps/api/app.js
Normal file
@ -0,0 +1,10 @@
|
||||
function treeify(o) {
|
||||
if (typeof(o) == 'object') {
|
||||
return Object.fromEntries(Object.keys(o).map(x => [x, treeify(o[x])]));
|
||||
} else if (typeof(o) == 'function') {
|
||||
return 'function';
|
||||
} else if (typeof(o) == 'string' || typeof(o) == 'number') {
|
||||
return o;
|
||||
}
|
||||
}
|
||||
app.setDocument(`<pre style="color:#fff">${JSON.stringify(treeify(globalThis), null, 2)}</pre>`);
|
4
apps/apps.json
Normal file
4
apps/apps.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "💻"
|
||||
}
|
77
apps/apps/app.js
Normal file
77
apps/apps/app.js
Normal file
@ -0,0 +1,77 @@
|
||||
async function fetch_info(apps) {
|
||||
let result = {};
|
||||
for (let [key, value] of Object.entries(apps)) {
|
||||
let blob = await ssb.blobGet(value);
|
||||
blob = blob ? utf8Decode(blob) : '{}';
|
||||
result[key] = JSON.parse(blob);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
var apps = await fetch_info(await core.apps());
|
||||
var core_apps = await fetch_info(await core.apps('core'));
|
||||
var doc = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
.container {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, 64px);
|
||||
justify-content: space-around;
|
||||
}
|
||||
.app {
|
||||
height: 96px;
|
||||
width: 64px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.app > a {
|
||||
text-decoration: none;
|
||||
max-width: 64px;
|
||||
text-overflow: ellipsis ellipsis;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body style="background: #888">
|
||||
<h1 id="apps_title">Apps</h1>
|
||||
<div id="apps" class="container"></div>
|
||||
<h1>Core Apps</h1>
|
||||
<div id="core_apps" class="container"></div>
|
||||
</body>
|
||||
<script>
|
||||
function populate_apps(id, name, apps) {
|
||||
var list = document.getElementById(id);
|
||||
for (let app of Object.keys(apps).sort()) {
|
||||
let div = list.appendChild(document.createElement('div'));
|
||||
div.classList.add('app');
|
||||
|
||||
let icon_a = document.createElement('a');
|
||||
let icon = document.createElement('div');
|
||||
icon.appendChild(document.createTextNode(apps[app].emoji || '📦'));
|
||||
icon.style.fontSize = 'xxx-large';
|
||||
icon_a.appendChild(icon);
|
||||
icon_a.href = '/~' + name + '/' + app + '/';
|
||||
icon_a.target = '_top';
|
||||
div.appendChild(icon_a);
|
||||
|
||||
let a = document.createElement('a');
|
||||
a.appendChild(document.createTextNode(app));
|
||||
a.href = '/~' + name + '/' + app + '/';
|
||||
a.target = '_top';
|
||||
div.appendChild(a);
|
||||
}
|
||||
}
|
||||
document.getElementById('apps_title').innerText = "~${escape(core.user.credentials?.session?.name || 'guest')}'s Apps";
|
||||
populate_apps('apps', '${core.user.credentials?.session?.name}', ${JSON.stringify(apps)});
|
||||
populate_apps('core_apps', 'core', ${JSON.stringify(core_apps)});
|
||||
</script>
|
||||
</html>`;
|
||||
app.setDocument(doc);
|
||||
}
|
||||
|
||||
main();
|
4
apps/appstore.json
Normal file
4
apps/appstore.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "🛍"
|
||||
}
|
55
apps/appstore/app.js
Normal file
55
apps/appstore/app.js
Normal file
@ -0,0 +1,55 @@
|
||||
async function get_apps() {
|
||||
let results = {};
|
||||
await ssb.sqlAsync(`
|
||||
SELECT messages.*
|
||||
FROM messages_fts('"application/tildefriends"')
|
||||
JOIN messages ON messages.rowid = messages_fts.rowid
|
||||
ORDER BY timestamp
|
||||
`,
|
||||
[],
|
||||
function(row) {
|
||||
let content = JSON.parse(row.content);
|
||||
for (let mention of content.mentions) {
|
||||
if (mention?.type === 'application/tildefriends') {
|
||||
results[JSON.stringify([row.author, mention.name])] = {
|
||||
message: row,
|
||||
blob: mention.link,
|
||||
name: mention.name,
|
||||
};
|
||||
}
|
||||
}
|
||||
});
|
||||
return Object.values(results).sort((x, y) => y.message.timestamp - x.message.timestamp);
|
||||
}
|
||||
|
||||
function render_app(app) {
|
||||
return `
|
||||
<div style="border: 2px solid white; display: inline-block; margin: 8px; padding: 8px">
|
||||
<a href="/~cory/ssb/#${app.message.author}">@</a>
|
||||
<a href="/~cory/ssb/#${app.message.id}">%</a>
|
||||
<a href="/${app.blob}/">${app.name}</a>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
let apps = await get_apps();
|
||||
app.setDocument(`
|
||||
<html>
|
||||
<head>
|
||||
<base target="_top">
|
||||
<style>
|
||||
a:link { color: #bbf; }
|
||||
a:visited { color: #ddd; }
|
||||
a:hover { color: #ddf; }
|
||||
</style>
|
||||
</head>
|
||||
<body style="color: #fff">
|
||||
<h1>${apps.length} apps</h1>
|
||||
${apps.map(render_app).join('\n')}
|
||||
</body>
|
||||
</html>
|
||||
`);
|
||||
}
|
||||
|
||||
main();
|
@ -1 +0,0 @@
|
||||
{"type":"tildefriends-app","files":{"app.js":"&uhGJsy5+qBgOgEgMqCTDasK+C+GWGptHKfPiAsD5eGA=.sha256","index.html":"&D3JwdPXy/QsLXkmwNDrBFXdzxfqO1/JGxfqEArnS5v4=.sha256","lit.min.js":"&3FfrVflmGr0n4lvN0GriN1Qz1lEw31SbZxRSJrcXR28=.sha256","script.js":"&TZ2ymD6cFVUjQleGcDslt8apjp7k3xLlfv2F8rQVM4I=.sha256"}}
|
@ -1 +0,0 @@
|
||||
{"type":"tildefriends-app","files":{"app.js":"&p35JmopfHf8hFh3Y9x6LrIxiUwaJZ5Nabzi2sVXpKoo=.sha256"}}
|
@ -1,11 +0,0 @@
|
||||
var global = Function('return this')();
|
||||
function treeify(o) {
|
||||
if (typeof(o) == 'object') {
|
||||
return Object.fromEntries(Object.keys(o).map(x => [x, treeify(o[x])]));
|
||||
} else if (typeof(o) == 'function') {
|
||||
return 'function';
|
||||
} else if (typeof(o) == 'string' || typeof(o) == 'number') {
|
||||
return o;
|
||||
}
|
||||
}
|
||||
app.setDocument(`<pre style="color:#fff">${JSON.stringify(treeify(global), null, 2)}</pre>`);
|
@ -1 +0,0 @@
|
||||
{"type":"tildefriends-app","files":{"app.js":"&qEJDfZ43KazIxiZl8OCKb2uaDOsPkxnIohEzQ1LLFpg=.sha256"}}
|
@ -1,31 +0,0 @@
|
||||
async function main() {
|
||||
var apps = await core.apps();
|
||||
var core_apps = await core.apps('core');
|
||||
var doc = `<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="background: #888">
|
||||
<h1>Apps</h1>
|
||||
<ul id="apps"></ul>
|
||||
<h1>Core Apps</h1>
|
||||
<ul id="core_apps"></ul>
|
||||
</body>
|
||||
<script>
|
||||
function populate_apps(id, name, apps) {
|
||||
var list = document.getElementById(id);
|
||||
for (let app of Object.keys(apps).sort()) {
|
||||
var li = list.appendChild(document.createElement('li'));
|
||||
var a = document.createElement('a');
|
||||
a.innerText = app;
|
||||
a.href = '/~' + name + '/' + app + '/';
|
||||
a.target = '_top';
|
||||
li.appendChild(a);
|
||||
}
|
||||
}
|
||||
populate_apps('apps', '${core.user.credentials?.session?.name}', ${JSON.stringify(apps)});
|
||||
populate_apps('core_apps', 'core', ${JSON.stringify(core_apps)});
|
||||
</script>
|
||||
</html>`
|
||||
app.setDocument(doc);
|
||||
}
|
||||
|
||||
main();
|
@ -1 +0,0 @@
|
||||
{"type":"tildefriends-app","files":{"app.js":"&V5o5IM9/OUyIsVkjkMW/X0i/tflQOSVJuJBmHdMT9aM=.sha256"}}
|
@ -1,70 +0,0 @@
|
||||
async function database_list() {
|
||||
var dbs = await databases();
|
||||
var doc = `<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="background: #888">
|
||||
<h1>Databases</h1>
|
||||
<ul id="dbs"></ul>
|
||||
</body>
|
||||
<script>
|
||||
function populate_dbs(id, dbs) {
|
||||
var list = document.getElementById(id);
|
||||
for (let db of dbs) {
|
||||
var li = list.appendChild(document.createElement('li'));
|
||||
var a = document.createElement('a');
|
||||
a.innerText = db;
|
||||
a.href = './#' + db;
|
||||
a.target = '_top';
|
||||
li.appendChild(a);
|
||||
}
|
||||
}
|
||||
populate_dbs('dbs', ${JSON.stringify(dbs)});
|
||||
</script>
|
||||
</html>`
|
||||
app.setDocument(doc);
|
||||
}
|
||||
|
||||
async function key_list(db) {
|
||||
let keys = await db.getAll();
|
||||
let object = {};
|
||||
for (let key of keys) {
|
||||
object[key] = await db.get(key);
|
||||
}
|
||||
let doc = `<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="background: #888">
|
||||
<a href="#" target="_top">back</a>
|
||||
<h1>Keys</h1>
|
||||
<ul id="keys"></ul>
|
||||
</body>
|
||||
<script>
|
||||
function populate_dbs(id, keys) {
|
||||
var list = document.getElementById(id);
|
||||
for (let [key, value] of Object.entries(keys)) {
|
||||
var li = list.appendChild(document.createElement('li'));
|
||||
li.innerText = key + ' = ' + value;
|
||||
}
|
||||
}
|
||||
populate_dbs('keys', ${JSON.stringify(object)});
|
||||
</script>
|
||||
</html>`
|
||||
app.setDocument(doc);
|
||||
}
|
||||
|
||||
core.register('message', async function(message) {
|
||||
if (message.event == 'hashChange') {
|
||||
let hash = message.hash.substring(1);
|
||||
if (hash.startsWith(':shared:')) {
|
||||
let parts = hash.split(':');
|
||||
let packageName = parts[3];
|
||||
let key = parts.slice(4).join(':');
|
||||
key_list(await my_shared_database(packageName, key));
|
||||
} else if (hash.length) {
|
||||
key_list(await database(hash.split(':').slice(1).join(':')));
|
||||
} else {
|
||||
database_list();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
database_list();
|
@ -1 +0,0 @@
|
||||
{"type":"tildefriends-app","files":{"app.js":"&WEvJYebSMi5d2eXgUwJJmvR/Q4slFg3zHYB8Q2mXJII=.sha256","index.md":"&Pi0NTJn9/w76yIUKqRRuSvUPSpqkxdYynmjeOBbF3K8=.sha256","todo.md":"&d8Kq8yuOn8SL3tJVy9BiDXHAe/jverpBj5AMLWLtmFM=.sha256","structure.md":"&T+CBfT9XP6ooKFvD1ZCI9hsutqsNIamfBxtAho0HtlU=.sha256","guide.md":"&SgnGL0+rjetY2o9A2+lVRbNvHIkqKwMnZr9gXWneIlc=.sha256","ssb.md":"&ouqT3XzTGfBNpOP/uEdOw7K1F9BeLZgQCx24XTvhyXU=.sha256"}}
|
@ -1,17 +0,0 @@
|
||||
# ID Refactor
|
||||
[Back to index](#index)
|
||||
|
||||
## Goals
|
||||
- no way to get private key in javascript
|
||||
- ssb.c syncs/broadcasts/... efficiently for everybody
|
||||
|
||||
## Schema
|
||||
- separate table to discourage leakage
|
||||
- `CREATE TABLE identities (user TEXT, public TEXT, secret TEXT);`
|
||||
|
||||
## API
|
||||
- `ssb.createIdentity()` -> `id`
|
||||
- `ssb.getIdentities()` => `[id, ...]`
|
||||
- `ssb.deleteIdentity(id)`
|
||||
- `ssb.post(id, ...)`
|
||||
- `ssb.appendMessage(id, ...)`
|
@ -1,11 +0,0 @@
|
||||
# Tilde Friends Documentation
|
||||
|
||||
Tilde Friends is a participating member of a greater social
|
||||
network, [Secure Scuttlebutt](https://scuttlebutt.nz/),
|
||||
augmenting it with a way to safely and securely write, share,
|
||||
and run code.
|
||||
|
||||
- [Secure Scuttlebutt from Scratch](#ssb)
|
||||
- [Structure](#structure)
|
||||
- [Guide](#guide)
|
||||
- [TODO](#todo)
|
@ -1 +0,0 @@
|
||||
{"type":"tildefriends-app","files":{"app.js":"&3d9ABFgRwQvWsYbFv/rzimtnLDnVrWlGtdw7serFIGw=.sha256"}}
|
@ -1,159 +0,0 @@
|
||||
"use strict";
|
||||
|
||||
var g_following_cache = {};
|
||||
var g_following_deep_cache = {};
|
||||
var g_about_cache = {};
|
||||
|
||||
async function following(db, id) {
|
||||
if (g_following_cache[id]) {
|
||||
return g_following_cache[id];
|
||||
}
|
||||
var o = await db.get(id + ":following");
|
||||
const k_version = 5;
|
||||
var f = o ? JSON.parse(o) : o;
|
||||
if (!f || f.version != k_version) {
|
||||
f = {users: [], sequence: 0, version: k_version};
|
||||
}
|
||||
f.users = new Set(f.users);
|
||||
await ssb.sqlStream(
|
||||
"SELECT "+
|
||||
" sequence, "+
|
||||
" json_extract(content, '$.contact') AS contact, "+
|
||||
" json_extract(content, '$.following') AS following "+
|
||||
"FROM messages "+
|
||||
"WHERE "+
|
||||
" author = ?1 AND "+
|
||||
" sequence > ?2 AND "+
|
||||
" json_extract(content, '$.type') = 'contact' "+
|
||||
"UNION SELECT MAX(sequence) AS sequence, NULL, NULL FROM messages WHERE author = ?1 "+
|
||||
"ORDER BY sequence",
|
||||
[id, f.sequence],
|
||||
function(row) {
|
||||
if (row.following) {
|
||||
f.users.add(row.contact);
|
||||
} else {
|
||||
f.users.delete(row.contact);
|
||||
}
|
||||
f.sequence = row.sequence;
|
||||
});
|
||||
var as_set = f.users;
|
||||
f.users = Array.from(f.users).sort();
|
||||
var j = JSON.stringify(f);
|
||||
if (o != j) {
|
||||
await db.set(id + ":following", j);
|
||||
}
|
||||
f.users = as_set;
|
||||
g_following_cache[id] = f.users;
|
||||
return f.users;
|
||||
}
|
||||
|
||||
async function followingDeep(db, seed_ids, depth) {
|
||||
if (depth <= 0) {
|
||||
return seed_ids;
|
||||
}
|
||||
var key = JSON.stringify([seed_ids, depth]);
|
||||
if (g_following_deep_cache[key]) {
|
||||
return g_following_deep_cache[key];
|
||||
}
|
||||
var f = await Promise.all(seed_ids.map(x => following(db, x).then(x => [...x])));
|
||||
var ids = [].concat(...f);
|
||||
var x = await followingDeep(db, [...new Set(ids)].sort(), depth - 1);
|
||||
x = [...new Set([].concat(...x, ...seed_ids))].sort();
|
||||
g_following_deep_cache[key] = x;
|
||||
return x;
|
||||
}
|
||||
|
||||
async function getAbout(db, id) {
|
||||
if (g_about_cache[id]) {
|
||||
return g_about_cache[id];
|
||||
}
|
||||
var o = await db.get(id + ":about");
|
||||
const k_version = 4;
|
||||
var f = o ? JSON.parse(o) : o;
|
||||
if (!f || f.version != k_version) {
|
||||
f = {about: {}, sequence: 0, version: k_version};
|
||||
}
|
||||
await ssb.sqlStream(
|
||||
"SELECT "+
|
||||
" sequence, "+
|
||||
" content "+
|
||||
"FROM messages "+
|
||||
"WHERE "+
|
||||
" author = ?1 AND "+
|
||||
" sequence > ?2 AND "+
|
||||
" json_extract(content, '$.type') = 'about' AND "+
|
||||
" json_extract(content, '$.about') = ?1 "+
|
||||
"UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 "+
|
||||
"ORDER BY sequence",
|
||||
[id, f.sequence],
|
||||
function(row) {
|
||||
f.sequence = row.sequence;
|
||||
if (row.content) {
|
||||
var about = {};
|
||||
try {
|
||||
about = JSON.parse(row.content);
|
||||
} catch {
|
||||
}
|
||||
delete about.about;
|
||||
delete about.type;
|
||||
f.about = Object.assign(f.about, about);
|
||||
}
|
||||
});
|
||||
var j = JSON.stringify(f);
|
||||
if (o != j) {
|
||||
await db.set(id + ":about", j);
|
||||
}
|
||||
g_about_cache[id] = f.about;
|
||||
return f.about;
|
||||
}
|
||||
|
||||
async function getSize(db, id) {
|
||||
let size = 0;
|
||||
await ssb.sqlStream(
|
||||
"SELECT (SUM(LENGTH(content)) + SUM(LENGTH(author)) + SUM(LENGTH(id))) AS size FROM messages WHERE author = ?1",
|
||||
[id],
|
||||
function (row) {
|
||||
size += row.size;
|
||||
});
|
||||
return size;
|
||||
}
|
||||
|
||||
function niceSize(bytes) {
|
||||
let value = bytes;
|
||||
let unit = 'B';
|
||||
const k_units = ['kB', 'MB', 'GB', 'TB'];
|
||||
for (let u of k_units) {
|
||||
if (value >= 1024) {
|
||||
value /= 1024;
|
||||
unit = u;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Math.round(value * 10) / 10 + ' ' + unit;
|
||||
}
|
||||
|
||||
async function buildTree(db, root, indent, depth) {
|
||||
var f = await following(db, root);
|
||||
var result = indent + '[' + f.size + '] ' + '<a target="_top" href="../index/#' + root + '">' + ((await getAbout(db, root)).name || root) + '</a> ' + niceSize(await getSize(db, root)) + '\n';
|
||||
if (depth > 0) {
|
||||
for (let next of f) {
|
||||
result += await buildTree(db, next, indent + ' ', depth - 1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await app.setDocument('<pre style="color: #fff">building...</pre>');
|
||||
var db = await database('ssb');
|
||||
var whoami = await ssb.getIdentities();
|
||||
var tree = '';
|
||||
for (let id of whoami) {
|
||||
await app.setDocument(`<pre style="color: #fff">building... ${id}</pre>`);
|
||||
tree += await buildTree(db, id, '', 2);
|
||||
}
|
||||
await app.setDocument('<pre style="color: #fff">FOLLOWING:\n' + tree + '</pre>');
|
||||
}
|
||||
|
||||
main();
|
@ -1 +0,0 @@
|
||||
{"type":"tildefriends-app","files":{"app.js":"&gxOJaVf/HdjVJVC9NvZ9n3/825OD1xMMHdF/dFQwe24=.sha256","lit-all.min.js":"&XKgdRySJuiZeZvchNFGjVWn0XOVhQFmG7/HTWYQ8s68=.sha256","index.html":"&TxhFekB9ov7tf/fmkAg7x5797i27oLidhgxEfDKC0T0=.sha256","script.js":"&G8puK9Q4MngHy3D4ppcKyT49WKbHD2OCeUcAw2ghTDE=.sha256","lit-all.min.js.map":"&lA9iFp1YbqSndxXZuwtgmrj7NDMkN71nJITbtjWL3VA=.sha256","tf-id-picker.js":"&maN8DUFrmRxW5nsVyOAMk5k1ekcz/pfzvSS99ac3jo8=.sha256","tf-app.js":"&7hclNu41CIoNk1JlXHiYmDPDyDIICZfMickJYtnF5eQ=.sha256","tf-message.js":"&oXFucwmn16nvKslQoGKTppO+71EoDZJE54z3WrlNUPI=.sha256","tf-user.js":"&bXTedgBudTQLXEBPY9R8OLfQ/ZLpo8YRU9Oq/wuGG3Y=.sha256","tf-utils.js":"&6RQUuxB3PkOhYEJr9+89Ptx7uijczjn0r035yCcQOQQ=.sha256","commonmark.min.js":"&bfBaMLU19d1p/vPBF9hlARqDX002KXG/UOfxOahZhe4=.sha256","tf-compose.js":"&y+Q47tdm60Od1UzuRu7OOLwineyQCL1LIb3KP5IwHTY=.sha256","emojis.json":"&h3P4pez+AI4aYdsN0dJ3pbUEFR0276t9AM20caj/W/s=.sha256","emojis.js":"&pqYLDE/13PyEt2ceeFqvnwZ8NqWfPfpDBt4vP8SeHbs=.sha256","tf-styles.js":"&LFeL/vWgrv4N8q/mBrQAnhbaOI+dXNJYvH9bn1bXSqQ=.sha256","tf-profile.js":"&vRKjsnYvOiHCQahzEfznCvP5YDwUPtltlpWf+pxwZ1Y=.sha256","commonmark-linkify.js":"&X+hNNkmSRvKY86khyAun+cXksquXbMakZdINbGbx30g=.sha256","tf-tab-search.js":"&ESt2vMG19sH5j6ungKua/ZuvIGslyuWyb3juXdOCecg=.sha256","tf-tab-news.js":"&F7T3LVS867x7vsKhYRR7eLNdCFZmrZ3JzEMfJEEKRm0=.sha256","tf-tab-connections.js":"&Ftt5RnkrhndV2lwC7XXUZX8JiUODqPjqEVgSTJQD6JU=.sha256","tf-news.js":"&gfG5LwXpugDkwDCOCOxQnNn0jLURZexSmvDu4SpQohA=.sha256","tribute.css":"&9FogMzZHKXCfGb7mlh7z+/wiNZzBsOB/tKoh6MfYJno=.sha256","tribute.esm.js":"&P1wKqCfYULpR/ahSB98JP8xaxfikuZwwtT6I/SAo7/Y=.sha256","commonmark-hashtag.js":"&H+V1OLA9GDdzycKclz276zAtSZLpT3rlNVa4+qQmp4o=.sha256"}}
|
@ -1,97 +0,0 @@
|
||||
import * as tfrpc from '/tfrpc.js';
|
||||
|
||||
let g_database;
|
||||
let g_hash;
|
||||
|
||||
tfrpc.register(async function localStorageGet(key) {
|
||||
return app.localStorageGet(key);
|
||||
});
|
||||
tfrpc.register(async function localStorageSet(key, value) {
|
||||
return app.localStorageSet(key, value);
|
||||
});
|
||||
tfrpc.register(async function databaseGet(key) {
|
||||
return g_database ? g_database.get(key) : undefined;
|
||||
});
|
||||
tfrpc.register(async function databaseSet(key, value) {
|
||||
return g_database ? g_database.set(key, value) : undefined;
|
||||
});
|
||||
tfrpc.register(async function createIdentity() {
|
||||
return ssb.createIdentity();
|
||||
});
|
||||
tfrpc.register(async function getIdentities() {
|
||||
return ssb.getIdentities();
|
||||
});
|
||||
tfrpc.register(async function getAllIdentities() {
|
||||
return ssb.getAllIdentities();
|
||||
});
|
||||
tfrpc.register(async function getBroadcasts() {
|
||||
return ssb.getBroadcasts();
|
||||
});
|
||||
tfrpc.register(async function getConnections() {
|
||||
return ssb.connections();
|
||||
});
|
||||
tfrpc.register(async function connectionSendJson(id, message) {
|
||||
return ssb.connectionSendJson(id, message);
|
||||
});
|
||||
tfrpc.register(async function createTunnel(portal, request_number, target) {
|
||||
let t = ssb.createTunnel(portal, request_number, target);
|
||||
return t;
|
||||
});
|
||||
tfrpc.register(async function connect(token) {
|
||||
await ssb.connect(token);
|
||||
});
|
||||
tfrpc.register(async function closeConnection(id) {
|
||||
await ssb.closeConnection(id);
|
||||
});
|
||||
tfrpc.register(async function query(sql, args) {
|
||||
let result = [];
|
||||
await ssb.sqlStream(sql, args, function callback(row) {
|
||||
result.push(row);
|
||||
});
|
||||
return result;
|
||||
});
|
||||
tfrpc.register(async function appendMessage(id, message) {
|
||||
return ssb.appendMessageWithIdentity(id, message);
|
||||
});
|
||||
core.register('message', async function message_handler(message) {
|
||||
if (message.event == 'hashChange') {
|
||||
g_hash = message.hash;
|
||||
await tfrpc.rpc.hashChanged(message.hash);
|
||||
}
|
||||
});
|
||||
tfrpc.register(function getHash(id, message) {
|
||||
return g_hash;
|
||||
});
|
||||
tfrpc.register(function setHash(hash) {
|
||||
return app.setHash(hash);
|
||||
});
|
||||
ssb.addEventListener('message', async function(id) {
|
||||
await tfrpc.rpc.notifyNewMessage(id);
|
||||
});
|
||||
tfrpc.register(async function store_blob(blob) {
|
||||
if (Array.isArray(blob)) {
|
||||
blob = Uint8Array.from(blob);
|
||||
}
|
||||
return await ssb.blobStore(blob);
|
||||
});
|
||||
tfrpc.register(async function get_blob(id) {
|
||||
return utf8Decode(await ssb.blobGet(id));
|
||||
});
|
||||
tfrpc.register(function apps() {
|
||||
return core.apps();
|
||||
});
|
||||
ssb.addEventListener('broadcasts', async function() {
|
||||
await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts());
|
||||
});
|
||||
|
||||
core.register('onConnectionsChanged', async function() {
|
||||
await tfrpc.rpc.set('connections', await ssb.connections());
|
||||
});
|
||||
|
||||
async function main() {
|
||||
if (typeof(database) !== 'undefined') {
|
||||
g_database = await database('ssb');
|
||||
}
|
||||
await app.setDocument(utf8Decode(await getFile('index.html')));
|
||||
}
|
||||
main();
|
@ -1,85 +0,0 @@
|
||||
let g_emojis;
|
||||
|
||||
function get_emojis() {
|
||||
if (g_emojis) {
|
||||
return Promise.resolve(g_emojis);
|
||||
}
|
||||
return fetch('emojis.json').then(function(result) {
|
||||
g_emojis = result.json();
|
||||
return g_emojis;
|
||||
});
|
||||
}
|
||||
|
||||
export function picker(callback, anchor) {
|
||||
get_emojis().then(function(json) {
|
||||
let existing = document.getElementById('emoji_picker');
|
||||
if (existing) {
|
||||
existing.parentElement.removeChild(existing);
|
||||
return;
|
||||
}
|
||||
let div = document.createElement('div');
|
||||
div.id = 'emoji_picker';
|
||||
div.style.color = '#000';
|
||||
div.style.background = '#fff';
|
||||
div.style.border = '1px solid #000';
|
||||
div.style.display = 'block';
|
||||
div.style.position = 'absolute';
|
||||
div.style.maxWidth = '16em';
|
||||
div.style.maxHeight = '16em';
|
||||
div.style.overflow = 'scroll';
|
||||
div.style.fontWeight = 'bold';
|
||||
let input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
div.appendChild(input);
|
||||
let list = document.createElement('div');
|
||||
div.appendChild(list);
|
||||
function refresh() {
|
||||
while (list.firstChild) {
|
||||
list.removeChild(list.firstChild);
|
||||
}
|
||||
let search = input.value;
|
||||
Object.entries(json).forEach(function(row) {
|
||||
let header = document.createElement('div');
|
||||
header.appendChild(document.createTextNode(row[0]));
|
||||
list.appendChild(header);
|
||||
let any = false;
|
||||
for (let entry of row[1]) {
|
||||
if (search &&
|
||||
search.length &&
|
||||
entry.name.indexOf(search) == -1) {
|
||||
continue;
|
||||
}
|
||||
let emoji = document.createElement('span');
|
||||
const k_size = '1.25em';
|
||||
emoji.style.width = k_size;
|
||||
emoji.style.maxWidth = k_size;
|
||||
emoji.style.minWidth = k_size;
|
||||
emoji.style.height = k_size;
|
||||
emoji.style.maxHeight = k_size;
|
||||
emoji.style.minHeight = k_size;
|
||||
emoji.style.display = 'inline-block';
|
||||
emoji.style.overflow = 'hidden';
|
||||
emoji.style.cursor = 'pointer';
|
||||
emoji.onclick = function() {
|
||||
callback(entry);
|
||||
div.parentElement.removeChild(div);
|
||||
}
|
||||
emoji.title = entry.name;
|
||||
emoji.appendChild(document.createTextNode(entry.emoji));
|
||||
list.appendChild(emoji);
|
||||
any = true;
|
||||
}
|
||||
if (!any) {
|
||||
list.removeChild(header);
|
||||
}
|
||||
});
|
||||
}
|
||||
refresh();
|
||||
input.oninput = refresh;
|
||||
document.body.appendChild(div);
|
||||
div.style.position = 'fixed';
|
||||
div.style.top = '50%';
|
||||
div.style.left = '50%';
|
||||
div.style.transform = 'translate(-50%, -50%)';
|
||||
});
|
||||
}
|
File diff suppressed because it is too large
Load Diff
@ -1,21 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html style="color: #fff">
|
||||
<head>
|
||||
<title>Tilde Friends</title>
|
||||
<base target="_top">
|
||||
<link rel="stylesheet" href="tribute.css" />
|
||||
<style>
|
||||
.tribute-container {
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<tf-app/>
|
||||
<script>window.litDisableBundleWarning = true;</script>
|
||||
<script src="commonmark.min.js"></script>
|
||||
<script src="commonmark-linkify.js" type="module"></script>
|
||||
<script src="commonmark-hashtag.js" type="module"></script>
|
||||
<script src="script.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
132
apps/cory/ssb/lit-all.min.js
vendored
132
apps/cory/ssb/lit-all.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@ -1,13 +0,0 @@
|
||||
import {LitElement, html} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
|
||||
import * as tf_id_picker from './tf-id-picker.js';
|
||||
import * as tf_app from './tf-app.js';
|
||||
import * as tf_message from './tf-message.js';
|
||||
import * as tf_user from './tf-user.js';
|
||||
import * as tf_compose from './tf-compose.js';
|
||||
import * as tf_news from './tf-news.js';
|
||||
import * as tf_profile from './tf-profile.js';
|
||||
import * as tf_tab_news from './tf-tab-news.js';
|
||||
import * as tf_tab_search from './tf-tab-search.js';
|
||||
import * as tf_tab_connections from './tf-tab-connections.js';
|
@ -1,265 +0,0 @@
|
||||
import {LitElement, html} from './lit-all.min.js';
|
||||
import * as tfutils from './tf-utils.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
import Tribute from './tribute.esm.js';
|
||||
|
||||
class TfComposeElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
users: {type: Object},
|
||||
root: {type: String},
|
||||
branch: {type: String},
|
||||
mentions: {type: Object},
|
||||
apps: {type: Object},
|
||||
}
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.users = {};
|
||||
this.root = undefined;
|
||||
this.branch = undefined;
|
||||
this.mentions = {};
|
||||
this.apps = undefined;
|
||||
}
|
||||
|
||||
changed(event) {
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
let preview = this.renderRoot.getElementById('preview');
|
||||
let text = edit.value;
|
||||
|
||||
/* Update mentions. */
|
||||
for (let match of text.matchAll(/\[([^\[]+)]\(([@&%][^\)]+)/g)) {
|
||||
let name = match[1];
|
||||
let link = match[2];
|
||||
let balance = 0;
|
||||
let bracket_end = match.index + match[1].length + '[]'.length - 1;
|
||||
for (let i = bracket_end; i >= 0; i--) {
|
||||
if (text.charAt(i) == ']') {
|
||||
balance++;
|
||||
} else if (text.charAt(i) == '[') {
|
||||
balance--;
|
||||
}
|
||||
if (balance <= 0) {
|
||||
name = text.substring(i + 1, bracket_end);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!this.mentions[link]) {
|
||||
this.mentions[link] = {
|
||||
link: link,
|
||||
}
|
||||
}
|
||||
this.mentions[link].name = name.startsWith('@') ? name.substring(1) : name;
|
||||
this.mentions = Object.assign({}, this.mentions);
|
||||
}
|
||||
|
||||
preview.innerHTML = tfutils.markdown(text);
|
||||
}
|
||||
|
||||
convert_to_webp(buffer, type) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
let img = new Image();
|
||||
img.onload = function() {
|
||||
let canvas = document.createElement('canvas');
|
||||
let width_scale = Math.min(img.width, 1024) / img.width;
|
||||
let height_scale = Math.min(img.height, 1024) / img.height;
|
||||
let scale = Math.min(width_scale, height_scale);
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
let context = canvas.getContext('2d');
|
||||
context.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
let data_url = canvas.toDataURL('image/webp');
|
||||
let result = atob(data_url.split(',')[1]).split('').map(x => x.charCodeAt(0));
|
||||
resolve(result);
|
||||
}
|
||||
img.onerror = function(event) {
|
||||
reject(new Error('Failed to load image.'));
|
||||
};
|
||||
let raw = Array.from(new Uint8Array(buffer)).map(b => String.fromCharCode(b)).join('');
|
||||
let original = `data:${type};base64,${btoa(raw)}`;
|
||||
img.src = original;
|
||||
});
|
||||
}
|
||||
|
||||
async add_file(file) {
|
||||
try {
|
||||
let self = this;
|
||||
let buffer = await file.arrayBuffer();
|
||||
let type = file.type;
|
||||
if (type.startsWith('image/')) {
|
||||
buffer = await self.convert_to_webp(buffer, file.type);
|
||||
type = 'image/webp';
|
||||
} else {
|
||||
buffer = Array.from(new Uint8Array(buffer));
|
||||
}
|
||||
let id = await tfrpc.rpc.store_blob(buffer);
|
||||
let name = type.split('/')[0] + ':' + file.name;
|
||||
self.mentions[id] = {
|
||||
link: id,
|
||||
name: name,
|
||||
type: type,
|
||||
size: buffer.length ?? buffer.byteLength,
|
||||
};
|
||||
self.mentions = Object.assign({}, self.mentions);
|
||||
let edit = self.renderRoot.getElementById('edit');
|
||||
edit.value += `\n`;
|
||||
self.changed();
|
||||
} catch(e) {
|
||||
alert(e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
paste(event) {
|
||||
let self = this;
|
||||
for (let item of event.clipboardData.items) {
|
||||
if (item.type?.startsWith('image/')) {
|
||||
let file = item.getAsFile();
|
||||
if (!file) {
|
||||
continue;
|
||||
}
|
||||
self.add_file(file);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
submit() {
|
||||
let self = this;
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
let message = {
|
||||
type: 'post',
|
||||
text: edit.value,
|
||||
};
|
||||
if (this.root || this.branch) {
|
||||
message.root = this.root;
|
||||
message.branch = this.branch;
|
||||
}
|
||||
if (Object.values(this.mentions).length) {
|
||||
message.mentions = Object.values(this.mentions);
|
||||
}
|
||||
console.log('Would post:', message);
|
||||
tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
|
||||
edit.value = '';
|
||||
self.mentions = {};
|
||||
self.changed();
|
||||
}).catch(function(error) {
|
||||
alert(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
discard() {
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
edit.value = '';
|
||||
this.changed();
|
||||
this.dispatchEvent(new CustomEvent('tf-discard'));
|
||||
}
|
||||
|
||||
attach() {
|
||||
let self = this;
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
let input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.onchange = function(event) {
|
||||
let file = event.target.files[0];
|
||||
self.add_file(file);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
let tribute = new Tribute({
|
||||
values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
|
||||
selectTemplate: function(item) {
|
||||
return `[@${item.original.key}](${item.original.value})`;
|
||||
},
|
||||
});
|
||||
tribute.attach(this.renderRoot.getElementById('edit'));
|
||||
}
|
||||
|
||||
remove_mention(id) {
|
||||
delete this.mentions[id];
|
||||
this.mentions = Object.assign({}, this.mentions);
|
||||
}
|
||||
|
||||
render_mention(mention) {
|
||||
let self = this;
|
||||
return html`
|
||||
<div>
|
||||
<pre style="white-space: pre-wrap">${JSON.stringify(mention, null, 2)}</pre>
|
||||
<input type="button" value="x" @click=${() => self.remove_mention(mention.link)}></input>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render_attach_app() {
|
||||
let self = this;
|
||||
|
||||
async function attach_selected_app() {
|
||||
let name = self.renderRoot.getElementById('select').value;
|
||||
let id = self.apps[name];
|
||||
let mentions = {};
|
||||
mentions[id] = {
|
||||
name: name,
|
||||
link: id,
|
||||
type: 'application/tildefriends',
|
||||
};
|
||||
if (name && id) {
|
||||
let app = JSON.parse(await tfrpc.rpc.get_blob(id));
|
||||
for (let entry of Object.entries(app.files)) {
|
||||
mentions[entry[1]] = {
|
||||
name: entry[0],
|
||||
link: entry[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
this.mentions = Object.assign(this.mentions || {}, mentions);
|
||||
this.apps = null;
|
||||
}
|
||||
|
||||
if (this.apps) {
|
||||
return html`
|
||||
<div>
|
||||
<select id="select">
|
||||
${Object.keys(self.apps).map(app => html`<option value=${app}>${app}</option>`)}
|
||||
</select>
|
||||
<input type="button" value="Attach" @click=${attach_selected_app}></input>
|
||||
<input type="button" value="Cancel" @click=${() => this.apps = null}></input>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
render_attach_app_button() {
|
||||
async function attach_app() {
|
||||
this.apps = await tfrpc.rpc.apps();
|
||||
}
|
||||
if (!this.apps) {
|
||||
return html`<input type="button" value="Attach App" @click=${attach_app}></input>`
|
||||
} else {
|
||||
return html`<input type="button" value="Discard App" @click=${() => this.apps = null}></input>`
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
let result = html`
|
||||
<div style="display: flex; flex-direction: row; width: 100%">
|
||||
<textarea id="edit" @input=${this.changed} @paste=${this.paste} style="flex: 1 0 50%"></textarea>
|
||||
<div id="preview" style="flex: 1 0 50%"></div>
|
||||
</div>
|
||||
${Object.values(this.mentions).map(x => self.render_mention(x))}
|
||||
${this.render_attach_app()}
|
||||
<input type="button" value="Submit" @click=${this.submit}></input>
|
||||
<input type="button" value="Attach" @click=${this.attach}></input>
|
||||
${this.render_attach_app_button()}
|
||||
<input type="button" value="Discard" @click=${this.discard}></input>
|
||||
`;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-compose', TfComposeElement);
|
@ -1,57 +0,0 @@
|
||||
import {LitElement, html} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
|
||||
class TfConnectionsElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
broadcasts: {type: Array},
|
||||
identities: {type: Array},
|
||||
connections: {type: Array},
|
||||
users: {type: Object},
|
||||
}
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.broadcasts = [];
|
||||
this.identities = [];
|
||||
this.connections = [];
|
||||
this.users = {};
|
||||
tfrpc.rpc.getAllIdentities().then(function(identities) {
|
||||
self.identities = identities || [];
|
||||
});
|
||||
}
|
||||
|
||||
_emit_change() {
|
||||
let changed_event = new Event('change', {
|
||||
srcElement: this,
|
||||
});
|
||||
this.dispatchEvent(changed_event);
|
||||
}
|
||||
|
||||
changed(event) {
|
||||
this.selected = event.srcElement.value;
|
||||
tfrpc.rpc.localStorageSet('whoami', this.selected);
|
||||
this._emit_change();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<h2>Broadcasts</h2>
|
||||
<ul>
|
||||
${this.broadcasts.map(x => html`<li><tf-user id=${x.pubkey} .users=${this.users}></tf-user></li>`)}
|
||||
</ul>
|
||||
<h2>Connections</h2>
|
||||
<ul>
|
||||
${this.connections.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)}
|
||||
</ul>
|
||||
<h2>Local Accounts</h2>
|
||||
<ul>
|
||||
${this.identities.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-connections', TfConnectionsElement);
|
@ -1,401 +0,0 @@
|
||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import * as tfutils from './tf-utils.js';
|
||||
import * as emojis from './emojis.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfMessageElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
message: {type: Object},
|
||||
users: {type: Object},
|
||||
reply: {type: Boolean},
|
||||
raw: {type: Boolean},
|
||||
collapsed: {type: Boolean},
|
||||
content_warning_expanded: {type: Boolean},
|
||||
blog_data: {type: String},
|
||||
blog_expanded: {type: Boolean},
|
||||
}
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.whoami = null;
|
||||
this.message = {};
|
||||
this.users = {};
|
||||
this.reply = false;
|
||||
this.raw = false;
|
||||
this.collapsed = false;
|
||||
}
|
||||
|
||||
show_reply() {
|
||||
this.reply = true;
|
||||
}
|
||||
|
||||
render_votes() {
|
||||
function normalize_expression(expression) {
|
||||
if (expression === 'Like' || !expression) {
|
||||
return '👍';
|
||||
} else if (expression === 'Unlike') {
|
||||
return '👎';
|
||||
} else if (expression === 'heart') {
|
||||
return '❤️';
|
||||
} else {
|
||||
return expression;
|
||||
}
|
||||
}
|
||||
return html`<div>${(this.message.votes || []).map(
|
||||
vote => html`
|
||||
<span title="${this.users[vote.author]?.name ?? vote.author} ${new Date(vote.timestamp)}">
|
||||
${normalize_expression(vote.content.vote.expression)}
|
||||
</span>
|
||||
`)}</div>`;
|
||||
}
|
||||
|
||||
render_raw() {
|
||||
let raw = {
|
||||
id: this.message?.id,
|
||||
previous: this.message?.previous,
|
||||
author: this.message?.author,
|
||||
sequence: this.message?.sequence,
|
||||
timestamp: this.message?.timestamp,
|
||||
hash: this.message?.hash,
|
||||
content: this.message?.content,
|
||||
signature: this.message?.signature,
|
||||
}
|
||||
return html`<div style="white-space: pre-wrap">${JSON.stringify(raw, null, 2)}</div>`
|
||||
}
|
||||
|
||||
vote(emoji) {
|
||||
let reaction = emoji.emoji;
|
||||
let message = this.message.id;
|
||||
if (confirm('Are you sure you want to react with ' + reaction + ' to ' + message + '?')) {
|
||||
tfrpc.rpc.appendMessage(
|
||||
this.whoami,
|
||||
{
|
||||
type: 'vote',
|
||||
vote: {
|
||||
link: message,
|
||||
value: 1,
|
||||
expression: reaction,
|
||||
},
|
||||
}).catch(function(error) {
|
||||
alert(error?.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
react(event) {
|
||||
emojis.picker(x => this.vote(x));
|
||||
}
|
||||
|
||||
show_image(link) {
|
||||
let div = document.createElement('div');
|
||||
div.style.left = 0;
|
||||
div.style.top = 0;
|
||||
div.style.width = '100%';
|
||||
div.style.height = '100%';
|
||||
div.style.position = 'fixed';
|
||||
div.style.background = '#000';
|
||||
div.style.zIndex = 100;
|
||||
div.style.display = 'grid';
|
||||
let img = document.createElement('img');
|
||||
img.src = link;
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.maxHeight = '100%';
|
||||
img.style.display = 'block';
|
||||
img.style.margin = 'auto';
|
||||
img.style.objectFit = 'contain';
|
||||
img.style.width = '100%';
|
||||
div.appendChild(img);
|
||||
function image_close(event) {
|
||||
document.body.removeChild(div);
|
||||
window.removeEventListener('keydown', image_close);
|
||||
}
|
||||
div.onclick = image_close;
|
||||
window.addEventListener('keydown', image_close);
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
body_click(event) {
|
||||
if (event.srcElement.tagName == 'IMG') {
|
||||
this.show_image(event.srcElement.src);
|
||||
}
|
||||
}
|
||||
|
||||
render_mention(mention) {
|
||||
if (!mention?.link || typeof(mention.link) != 'string') {
|
||||
return html` <pre>${JSON.stringify(mention)}</pre>`;
|
||||
} else if (mention?.link?.startsWith('&') &&
|
||||
mention?.type?.startsWith('image/')) {
|
||||
return html`
|
||||
<img src=${'/' + mention.link + '/view'} style="max-width: 128px; max-height: 128px" title=${mention.name} @click=${() => this.show_image('/' + mention.link + '/view')}>
|
||||
`;
|
||||
} else if (mention.link?.startsWith('&') &&
|
||||
mention.name?.startsWith('audio:')) {
|
||||
return html`
|
||||
<audio controls style="height: 32px">
|
||||
<source src=${'/' + mention.link + '/view'}></source>
|
||||
</audio>
|
||||
`;
|
||||
} else if (mention.link?.startsWith('&') &&
|
||||
mention.name?.startsWith('video:')) {
|
||||
return html`
|
||||
<video controls style="max-height: 240px">
|
||||
<source src=${'/' + mention.link + '/view'}></source>
|
||||
</video>
|
||||
`;
|
||||
} else if (mention.link?.startsWith('&') &&
|
||||
mention?.type === 'application/tildefriends') {
|
||||
return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`;
|
||||
} else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) {
|
||||
return html` <a href=${'#' + encodeURIComponent(mention.link)}>${mention.name}</a>`;
|
||||
} else if (mention.link?.startsWith('#')) {
|
||||
return html` <a href=${'#q=' + encodeURIComponent(mention.link)}>${mention.link}</a>`;
|
||||
} else if (Object.keys(mention).length == 2 && mention.link && mention.name) {
|
||||
return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`;
|
||||
} else {
|
||||
return html` <pre style="white-space: pre-wrap">${JSON.stringify(mention, null, 2)}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
render_mentions() {
|
||||
let mentions = this.message?.content?.mentions || [];
|
||||
mentions = mentions.filter(x =>
|
||||
x.name?.startsWith('audio:') ||
|
||||
x.name?.startsWith('video:') ||
|
||||
this.message?.content?.text?.indexOf(x.link) === -1);
|
||||
if (mentions.length) {
|
||||
let self = this;
|
||||
return html`
|
||||
<fieldset style="background-color: rgba(0, 0, 0, 0.1); padding: 0.5em; border: 1px solid black">
|
||||
<legend>Mentions</legend>
|
||||
${mentions.map(x => self.render_mention(x))}
|
||||
</fieldset>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
render_children() {
|
||||
let self = this;
|
||||
if (this.message.child_messages?.length) {
|
||||
if (this.collapsed) {
|
||||
return html`<input type="button" value=${this.message.child_messages?.length + ' More'} @click=${() => self.collapsed = false}></input>`;
|
||||
} else {
|
||||
return html`<input type="button" value="Collapse" @click=${() => self.collapsed = true}></input>${(this.message.child_messages || []).map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users}></tf-message>`)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let content = this.message?.content;
|
||||
let self = this;
|
||||
let raw_button = this.raw ?
|
||||
html`<input type="button" value="Message" @click=${() => self.raw = false}></input>` :
|
||||
html`<input type="button" value="Raw" @click=${() => self.raw = true}></input>`;
|
||||
function small_frame(inner) {
|
||||
return html`
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block">
|
||||
<tf-user id=${self.message.author} .users=${self.users}></tf-user>
|
||||
<span style="padding-right: 8px"><a tfarget="_top" href=${'#' + self.message.id}>%</a> ${new Date(self.message.timestamp).toLocaleString()}</span>
|
||||
${raw_button}
|
||||
${self.raw ? self.render_raw() : inner}
|
||||
${self.render_votes()}
|
||||
</div>
|
||||
`
|
||||
}
|
||||
if (this.message.placeholder) {
|
||||
return html`
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere">
|
||||
<a target="_top" href=${'#' + this.message.id}>${this.message.id}</a> (placeholder)
|
||||
<div>${this.render_votes()}</div>
|
||||
${(this.message.child_messages || []).map(x => html`
|
||||
<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} collapsed=true></tf-message>
|
||||
`)}
|
||||
</div>`;
|
||||
} else if (typeof(content?.type === 'string')) {
|
||||
if (content.type == 'about') {
|
||||
let name;
|
||||
let image;
|
||||
let description;
|
||||
if (content.name !== undefined) {
|
||||
name = html`<div><b>Name:</b> ${content.name}</div>`;
|
||||
}
|
||||
if (content.image !== undefined) {
|
||||
image = html`
|
||||
<div><img src=${'/' + content.image + '/view'} style="width: 256px; height: auto"></img></div>
|
||||
`;
|
||||
}
|
||||
if (content.description !== undefined) {
|
||||
description = html`
|
||||
<div style="flex: 1 0 50%">
|
||||
<div>${unsafeHTML(tfutils.markdown(content.description))}</div>
|
||||
</div>
|
||||
`
|
||||
}
|
||||
return small_frame(html`
|
||||
<div style="font-weight: bold">Updated profile.</div>
|
||||
${name}
|
||||
${image}
|
||||
${description}
|
||||
`);
|
||||
} else if (content.type == 'contact') {
|
||||
return small_frame(html`
|
||||
<div>
|
||||
is
|
||||
${
|
||||
content.blocking === true ? 'blocking' :
|
||||
content.blocking === false ? 'no longer blocking' :
|
||||
content.following === true ? 'following' :
|
||||
content.following === false ? 'no longer following' :
|
||||
'?'
|
||||
}
|
||||
<tf-user id=${this.message.content.contact} .users=${this.users}></tf-user>
|
||||
</div>
|
||||
`);
|
||||
} else if (content.type == 'post') {
|
||||
let reply = this.reply ? html`
|
||||
<tf-compose
|
||||
?enabled=${this.reply}
|
||||
whoami=${this.whoami}
|
||||
.users=${this.users}
|
||||
root=${this.message.content.root || this.message.id}
|
||||
branch=${this.message.id}
|
||||
@tf-discard=${() => this.reply = false}></tf-compose>
|
||||
` : html`
|
||||
<input type="button" value="Reply" @click=${this.show_reply}></input>
|
||||
`;
|
||||
let self = this;
|
||||
let body = this.raw ?
|
||||
this.render_raw() :
|
||||
unsafeHTML(tfutils.markdown(content.text));
|
||||
let content_warning = html`
|
||||
<div style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px" @click=${x => self.content_warning_expanded = !self.content_warning_expanded}>${content.contentWarning}</div>
|
||||
`;
|
||||
let content_html =
|
||||
html`
|
||||
<div @click=${this.body_click}>${body}</div>
|
||||
${this.render_mentions()}
|
||||
`;
|
||||
let payload =
|
||||
content.contentWarning ?
|
||||
self.content_warning_expanded ?
|
||||
html`
|
||||
${content_warning}
|
||||
${content_html}
|
||||
` :
|
||||
content_warning :
|
||||
content_html;
|
||||
return html`
|
||||
<style>
|
||||
code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
div {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px">
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
|
||||
<span style="flex: 1"></span>
|
||||
<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span>
|
||||
<span>${raw_button}</span>
|
||||
</div>
|
||||
${payload}
|
||||
${this.render_votes()}
|
||||
<div>
|
||||
${reply}
|
||||
<input type="button" value="React" @click=${this.react}></input>
|
||||
</div>
|
||||
${this.render_children()}
|
||||
</div>
|
||||
`;
|
||||
} else if (content.type === 'blog') {
|
||||
let self = this;
|
||||
console.log('requesting data');
|
||||
tfrpc.rpc.get_blob(content.blog).then(function(data) {
|
||||
self.blog_data = data;
|
||||
});
|
||||
let payload =
|
||||
this.blog_expanded ?
|
||||
html`<div>${this.blog_data ? unsafeHTML(tfutils.markdown(this.blog_data)) : 'Loading...'}</div>` :
|
||||
undefined;
|
||||
let body = this.raw ?
|
||||
this.render_raw() :
|
||||
html`
|
||||
<div
|
||||
style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer"
|
||||
@click=${x => self.blog_expanded = !self.blog_expanded}>
|
||||
<h2>${content.title}</h2>
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<img src=/${content.thumbnail}/view></img>
|
||||
<span>${content.summary}</span>
|
||||
</div>
|
||||
</div>
|
||||
${payload}
|
||||
`;
|
||||
return html`
|
||||
<style>
|
||||
code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
div {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px">
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
|
||||
<span style="flex: 1"></span>
|
||||
<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span>
|
||||
<span>${raw_button}</span>
|
||||
</div>
|
||||
|
||||
<div>${body}</div>
|
||||
${this.render_mentions()}
|
||||
${this.render_votes()}
|
||||
</div>
|
||||
`;
|
||||
} else if (content.type === 'pub') {
|
||||
return small_frame(html`
|
||||
<span>
|
||||
<div>
|
||||
🍻 <tf-user .users=${this.users} id=${content.address.key}></tf-user>
|
||||
</div>
|
||||
<pre>${content.address.host}:${content.address.port}</pre>
|
||||
</span>`);
|
||||
} else if (content.type === 'channel') {
|
||||
return small_frame(html`
|
||||
<div>
|
||||
${content.subscribed ? 'subscribed to' : 'unsubscribed from'} <a href=${'#q=' + encodeURIComponent('#' + content.channel)}>#${content.channel}</a>
|
||||
</div>
|
||||
`);
|
||||
} else if (typeof(this.message.content) == 'string') {
|
||||
return small_frame(html`<span>🔒</span>`);
|
||||
} else {
|
||||
return small_frame(html`<div><b>type</b>: ${content.type}</div>`);
|
||||
}
|
||||
} else {
|
||||
return small_frame(this.render_raw());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-message', TfMessageElement);
|
@ -1,32 +0,0 @@
|
||||
import {css} from './lit-all.min.js';
|
||||
|
||||
export let styles = css`
|
||||
a:link {
|
||||
color: #bbf;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #ddf;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: min(640px, 100%);
|
||||
max-height: min(480px, auto);
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 0;
|
||||
padding: 8px;
|
||||
margin: 0px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab:disabled {
|
||||
color: #088;
|
||||
background-color: #fff;
|
||||
}
|
||||
`;
|
@ -1,174 +0,0 @@
|
||||
import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfTabNewsFeedElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
users: {type: Object},
|
||||
hash: {type: String},
|
||||
following: {type: Array},
|
||||
messages: {type: Array},
|
||||
}
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.whoami = null;
|
||||
this.users = {};
|
||||
this.hash = '#';
|
||||
this.following = [];
|
||||
}
|
||||
|
||||
async fetch_messages() {
|
||||
if (this.hash.startsWith('#@')) {
|
||||
let r = await tfrpc.rpc.query(
|
||||
`
|
||||
WITH mine AS (SELECT messages.*
|
||||
FROM messages
|
||||
WHERE messages.author = ?
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 20)
|
||||
SELECT messages.*
|
||||
FROM mine
|
||||
JOIN messages_refs ON mine.id = messages_refs.ref
|
||||
JOIN messages ON messages_refs.message = messages.id
|
||||
UNION
|
||||
SELECT * FROM mine
|
||||
`,
|
||||
[
|
||||
this.hash.substring(1),
|
||||
]);
|
||||
return r;
|
||||
} else if (this.hash.startsWith('#%')) {
|
||||
return await tfrpc.rpc.query(
|
||||
`
|
||||
SELECT messages.*
|
||||
FROM messages
|
||||
WHERE id = ?1
|
||||
UNION
|
||||
SELECT messages.*
|
||||
FROM messages JOIN messages_refs
|
||||
ON messages.id = messages_refs.message
|
||||
WHERE messages_refs.ref = ?1
|
||||
`,
|
||||
[
|
||||
this.hash.substring(1),
|
||||
]);
|
||||
} else {
|
||||
return await tfrpc.rpc.query(
|
||||
`
|
||||
WITH news AS (SELECT messages.*
|
||||
FROM messages
|
||||
JOIN json_each(?) AS following ON messages.author = following.value
|
||||
WHERE messages.timestamp > ?
|
||||
ORDER BY messages.timestamp DESC)
|
||||
SELECT messages.*
|
||||
FROM news
|
||||
JOIN messages_refs ON news.id = messages_refs.ref
|
||||
JOIN messages ON messages_refs.message = messages.id
|
||||
UNION
|
||||
SELECT messages.*
|
||||
FROM news
|
||||
JOIN messages_refs ON news.id = messages_refs.message
|
||||
JOIN messages ON messages_refs.ref = messages.id
|
||||
UNION
|
||||
SELECT news.* FROM news
|
||||
`,
|
||||
[
|
||||
JSON.stringify(this.following),
|
||||
new Date().valueOf() - 24 * 60 * 60 * 1000,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.messages ||
|
||||
this._messages_hash !== this.hash ||
|
||||
this._messages_following !== this.following) {
|
||||
console.log(`loading messages for ${this.whoami}`);
|
||||
let self = this;
|
||||
this.messages = [];
|
||||
this._messages_hash = this.hash;
|
||||
this._messages_following = this.following;
|
||||
this.fetch_messages().then(function(messages) {
|
||||
self.messages = messages;
|
||||
console.log(`loading mesages done for ${self.whoami}`);
|
||||
}).catch(function(error) {
|
||||
alert(JSON.stringify(error, null, 2));
|
||||
});
|
||||
}
|
||||
return html`<tf-news id="news" whoami=${this.whoami} .users=${this.users} .messages=${this.messages} .following=${this.following}></tf-news>`;
|
||||
}
|
||||
}
|
||||
|
||||
class TfTabNewsElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
users: {type: Object},
|
||||
hash: {type: String},
|
||||
unread: {type: Array},
|
||||
following: {type: Array},
|
||||
}
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.whoami = null;
|
||||
this.users = {};
|
||||
this.hash = '#';
|
||||
this.unread = [];
|
||||
this.following = [];
|
||||
this.cache = {};
|
||||
}
|
||||
|
||||
show_more() {
|
||||
let unread = this.unread;
|
||||
let news = this.renderRoot?.getElementById('news');
|
||||
if (news) {
|
||||
console.log('injecting messages', news.messages);
|
||||
news.messages = Object.values(Object.fromEntries([...this.unread, ...news.messages].map(x => [x.id, x])));
|
||||
this.dispatchEvent(new CustomEvent('refresh'));
|
||||
}
|
||||
}
|
||||
|
||||
new_messages_text() {
|
||||
if (!this.unread?.length) {
|
||||
return 'No new messages.';
|
||||
}
|
||||
let counts = {};
|
||||
for (let message of this.unread) {
|
||||
let type = 'private';
|
||||
try {
|
||||
type = JSON.parse(message.content).type || type;
|
||||
} catch {
|
||||
}
|
||||
counts[type] = (counts[type] || 0) + 1;
|
||||
}
|
||||
return 'Show New: ' + Object.keys(counts).sort().map(x => (counts[x].toString() + ' ' + x + 's')).join(', ');
|
||||
}
|
||||
|
||||
render() {
|
||||
let profile = this.hash.startsWith('#@') ?
|
||||
html`<tf-profile id=${this.hash.substring(1)} whoami=${this.whoami} .users=${this.users}></tf-profile>` : undefined;
|
||||
return html`
|
||||
<div><input type="button" value=${this.new_messages_text()} @click=${this.show_more}></input></div>
|
||||
<a target="_top" href="#" ?hidden=${this.hash.length <= 1}>🏠Home</a>
|
||||
<div>Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!</div>
|
||||
<div><tf-compose whoami=${this.whoami} .users=${this.users}></tf-compose></div>
|
||||
${profile}
|
||||
<tf-tab-news-feed id="news" whoami=${this.whoami} .users=${this.users} .following=${this.following} hash=${this.hash}></tf-tab-news-feed>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-tab-news-feed', TfTabNewsFeedElement);
|
||||
customElements.define('tf-tab-news', TfTabNewsElement);
|
@ -1,39 +0,0 @@
|
||||
import {LitElement, html} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfUserElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
id: {type: String},
|
||||
users: {type: Object},
|
||||
}
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.id = null;
|
||||
this.users = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.users[this.id]) {
|
||||
let image = this.users[this.id].image;
|
||||
image = typeof(image) == 'string' ? image : image?.link;
|
||||
return html`
|
||||
<div style="display: inline-block; font-weight: bold">
|
||||
<img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" ?hidden=${image === undefined} src="${image ? '/' + image + '/view' : undefined}">
|
||||
<a target="_top" href=${'#' + this.id}>${this.users[this.id].name ?? this.id}</a>
|
||||
</div>`;
|
||||
} else {
|
||||
return html`
|
||||
<div style="display: inline-block; font-weight: bold; word-wrap: anywhere">
|
||||
<a target="_top" href=${'#' + this.id}>${this.id}</a>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-user', TfUserElement);
|
@ -1,48 +0,0 @@
|
||||
import * as linkify from './commonmark-linkify.js';
|
||||
import * as hashtagify from './commonmark-hashtag.js';
|
||||
|
||||
export function markdown(md) {
|
||||
var reader = new commonmark.Parser({safe: true});
|
||||
var writer = new commonmark.HtmlRenderer();
|
||||
var parsed = reader.parse(md || '');
|
||||
parsed = hashtagify.transform(parsed);
|
||||
parsed = linkify.transform(parsed);
|
||||
var walker = parsed.walker();
|
||||
var event, node;
|
||||
while ((event = walker.next())) {
|
||||
node = event.node;
|
||||
if (event.entering) {
|
||||
if (node.type == 'link') {
|
||||
if (node.destination.startsWith('@') &&
|
||||
node.destination.endsWith('.ed25519')) {
|
||||
node.destination = '#' + node.destination;
|
||||
} else if (node.destination.startsWith('%') &&
|
||||
node.destination.endsWith('.sha256')) {
|
||||
node.destination = '#' + node.destination;
|
||||
} else if (node.destination.startsWith('&') &&
|
||||
node.destination.endsWith('.sha256')) {
|
||||
node.destination = '/' + node.destination + '/view';
|
||||
}
|
||||
} else if (node.type == 'image') {
|
||||
if (node.destination.startsWith('&')) {
|
||||
node.destination = '/' + node.destination + '/view';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return writer.render(parsed);
|
||||
}
|
||||
|
||||
export function human_readable_size(bytes) {
|
||||
let v = bytes;
|
||||
let u = 'B';
|
||||
for (let unit of ['kB', 'MB', 'GB']) {
|
||||
if (v > 1024) {
|
||||
v /= 1024;
|
||||
u = unit;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return `${Math.round(v * 10) / 10} ${u}`;
|
||||
}
|
@ -1 +0,0 @@
|
||||
{"type":"tildefriends-app","files":{"app.js":"&QUR1tKa15B5Or8AfPX/8Zs87teSeX0Mh/HF7PEPBom0=.sha256","index.html":"&QXhwvxhHc9fa8iL6088hGDu9FgWdY7wkXgvU2BMNv0A=.sha256","lit-core.min.js":"&tP9KhbgwF1chFqPtkNZ12Yx9AfkpnSjFiPcX5Pw5J9g=.sha256","script.js":"&KgOaUVjBM4MzSy7PpUVQHETuvgXAx2JGPJABksBg+QY=.sha256"}}
|
@ -1,187 +0,0 @@
|
||||
import {LitElement, html} from './lit-core.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
|
||||
class TodosElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
lists: {type: Array}
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.lists = [];
|
||||
let self = this;
|
||||
tfrpc.rpc.todo_get_all().then(function(lists) {
|
||||
self.lists = lists;
|
||||
}).catch(function(error) {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
|
||||
async new_list() {
|
||||
await tfrpc.rpc.todo_add('new list');
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.lists = await tfrpc.rpc.todo_get_all();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div>
|
||||
<div style="display: flex">
|
||||
${this.lists.map(x => html`
|
||||
<tf-todo-list name=${x.name} .items=${x.items} @change=${this.refresh}></tf-todo-list>
|
||||
`)}
|
||||
</div>
|
||||
<input type="button" @click=${this.new_list} value="+ List"></input>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
class TodoListElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
name: {type: String},
|
||||
items: {type: Array},
|
||||
editing: {type: Number},
|
||||
editing_name: {type: Boolean},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
save() {
|
||||
let self = this;
|
||||
console.log('saving', self.name, self.items);
|
||||
tfrpc.rpc.todo_set(self.name, self.items).then(function() {
|
||||
console.log('saved', self.name, self.items);
|
||||
}).catch(function(error) {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
|
||||
remove_item(item) {
|
||||
let index = this.items.indexOf(item);
|
||||
this.items = [].concat(this.items.slice(0, index), this.items.slice(index + 1));
|
||||
this.save();
|
||||
}
|
||||
|
||||
handle_check(event, item) {
|
||||
item.x = event.srcElement.checked;
|
||||
this.save();
|
||||
}
|
||||
|
||||
input_blur(item) {
|
||||
this.save();
|
||||
this.editing = undefined;
|
||||
}
|
||||
|
||||
input_change(event, item) {
|
||||
item.text = event.srcElement.value;
|
||||
}
|
||||
|
||||
input_keydown(event, item) {
|
||||
if (event.key === 'Enter' || event.key === 'Escape') {
|
||||
item.text = event.srcElement.value;
|
||||
this.editing = undefined;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
updated() {
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
if (edit) {
|
||||
edit.select();
|
||||
}
|
||||
}
|
||||
|
||||
render_item(item) {
|
||||
let index = this.items.indexOf(item);
|
||||
let self = this;
|
||||
if (index === this.editing) {
|
||||
return html`
|
||||
<div><input type="checkbox" ?checked=${item.x} @change=${x => self.handle_check(x, item)}></input>
|
||||
<input
|
||||
id="edit"
|
||||
type="text"
|
||||
value=${item.text}
|
||||
@change=${event => self.input_change(event, item)}
|
||||
@keydown=${event => self.input_keydown(event, item)}
|
||||
@blur=${x => self.input_blur(item)}></input>
|
||||
<span @click=${x => self.remove_item(item)}>x</span></div>
|
||||
`;
|
||||
} else {
|
||||
return html`
|
||||
<div><input type="checkbox" ?checked=${item.x} @change=${x => self.handle_check(x, item)}></input>
|
||||
<span @click=${x => self.editing = index}>${item.text}</span>
|
||||
<span @click=${x => self.remove_item(item)} style="cursor: pointer">❎</span></div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
add_item() {
|
||||
this.items = [].concat(this.items || [], [{text: 'new item'}]);
|
||||
this.editing = this.items.length - 1;
|
||||
this.save();
|
||||
}
|
||||
|
||||
async remove_list() {
|
||||
if (confirm(`Are you sure you want to remove "${this.name}"?`)) {
|
||||
await tfrpc.rpc.todo_remove(this.name);
|
||||
this.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
rename(new_name) {
|
||||
let self = this;
|
||||
return tfrpc.rpc.todo_rename(this.name, new_name).then(function() {
|
||||
self.dispatchEvent(new Event('change'));
|
||||
self.editing_name = false;
|
||||
}).catch(function(error) {
|
||||
console.log(error);
|
||||
alert(error.message);
|
||||
self.editing_name = false;
|
||||
});
|
||||
}
|
||||
|
||||
name_blur(new_name) {
|
||||
this.rename(new_name);
|
||||
}
|
||||
|
||||
name_keydown(event, item) {
|
||||
let self = this;
|
||||
if (event.key == 'Enter' || event.key === 'Escape') {
|
||||
let new_name = event.srcElement.value;
|
||||
this.rename(new_name);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
let name = this.editing_name ?
|
||||
html`<input
|
||||
type="text"
|
||||
id="edit"
|
||||
@keydown=${event => self.name_keydown(event)}
|
||||
@blur=${event => self.name_blur(event.srcElement.value)}
|
||||
value=${this.name}></input>` :
|
||||
html`<h2 @click=${x => this.editing_name = true}>${this.name}</h2>`;
|
||||
return html`
|
||||
<div style="border: 3px solid black; padding: 8px; margin: 8px; border-radius: 8px; background-color: #444">
|
||||
${name}
|
||||
${(this.items || []).map(x => self.render_item(x))}
|
||||
<button @click=${self.add_item}>+ Item</button>
|
||||
<button @click=${self.remove_list}>- List</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-todo-list', TodoListElement);
|
||||
customElements.define('tf-todos', TodosElement);
|
4
apps/db.json
Normal file
4
apps/db.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "💽"
|
||||
}
|
70
apps/db/app.js
Normal file
70
apps/db/app.js
Normal file
@ -0,0 +1,70 @@
|
||||
async function database_list() {
|
||||
var dbs = await databases();
|
||||
var doc = `<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="background: #888">
|
||||
<h1>Databases</h1>
|
||||
<ul id="dbs"></ul>
|
||||
</body>
|
||||
<script>
|
||||
function populate_dbs(id, dbs) {
|
||||
var list = document.getElementById(id);
|
||||
for (let db of dbs) {
|
||||
var li = list.appendChild(document.createElement('li'));
|
||||
var a = document.createElement('a');
|
||||
a.innerText = db;
|
||||
a.href = './#' + db;
|
||||
a.target = '_top';
|
||||
li.appendChild(a);
|
||||
}
|
||||
}
|
||||
populate_dbs('dbs', ${JSON.stringify(dbs)});
|
||||
</script>
|
||||
</html>`;
|
||||
app.setDocument(doc);
|
||||
}
|
||||
|
||||
async function key_list(db) {
|
||||
let keys = await db.getAll();
|
||||
let object = {};
|
||||
for (let key of keys) {
|
||||
object[key] = await db.get(key);
|
||||
}
|
||||
let doc = `<!DOCTYPE html>
|
||||
<html>
|
||||
<body style="background: #888">
|
||||
<a href="#" target="_top">back</a>
|
||||
<h1>Keys</h1>
|
||||
<ul id="keys"></ul>
|
||||
</body>
|
||||
<script>
|
||||
function populate_dbs(id, keys) {
|
||||
var list = document.getElementById(id);
|
||||
for (let [key, value] of Object.entries(keys)) {
|
||||
var li = list.appendChild(document.createElement('li'));
|
||||
li.innerText = key + ' = ' + value;
|
||||
}
|
||||
}
|
||||
populate_dbs('keys', ${JSON.stringify(object)});
|
||||
</script>
|
||||
</html>`;
|
||||
app.setDocument(doc);
|
||||
}
|
||||
|
||||
core.register('message', async function(message) {
|
||||
if (message.event == 'hashChange') {
|
||||
let hash = message.hash.substring(1);
|
||||
if (hash.startsWith(':shared:')) {
|
||||
let parts = hash.split(':');
|
||||
let packageName = parts[3];
|
||||
let key = parts.slice(4).join(':');
|
||||
key_list(await my_shared_database(packageName, key));
|
||||
} else if (hash.length) {
|
||||
key_list(await database(hash.split(':').slice(1).join(':')));
|
||||
} else {
|
||||
database_list();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
database_list();
|
4
apps/docs.json
Normal file
4
apps/docs.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "📚"
|
||||
}
|
12
apps/docs/index.md
Normal file
12
apps/docs/index.md
Normal file
@ -0,0 +1,12 @@
|
||||
# Tilde Friends Documentation
|
||||
|
||||
Tilde Friends is a participating member of a greater social
|
||||
network, [Secure Scuttlebutt](https://scuttlebutt.nz/),
|
||||
adding a way to safely and securely write, share,
|
||||
and run code in the form of server-side web applications.
|
||||
|
||||
- [Tilde Friends Vision](#vision)
|
||||
- [Secure Scuttlebutt from Scratch](#ssb)
|
||||
- [Structure](#structure)
|
||||
- [Guide](#guide)
|
||||
- [TODO](#todo)
|
@ -16,6 +16,8 @@ IPv4 addresses.
|
||||
|
||||
So be prepared to accept variations.
|
||||
|
||||
There also an undocumented "new" style of discovery message.
|
||||
|
||||
## Secret Handshake, Box Stream, and RPC Protocol
|
||||
Now that two clients are aware of eachother, they need to complete a secret handshake.
|
||||
The [programming guide](https://ssbc.github.io/scuttlebutt-protocol-guide/#handshake)
|
||||
@ -26,12 +28,14 @@ The box stream and RPC protocol can both be implemented from the
|
||||
without surprises.
|
||||
|
||||
## Synchronizing Data
|
||||
So now you're discovering other clients on the local network, connecting, performing
|
||||
a secret handshake, and making remote procedure calls over box streams. The next step
|
||||
is to start synchronizing feeds over the network. The goal, after all, is to author
|
||||
messages in your local append-only log and have them show up in distant clients, or
|
||||
vice versa.
|
||||
|
||||
... `ebt.replicate` or `createHistoryStream` ...
|
||||
|
||||
## Rooms
|
||||
|
||||
TODO
|
||||
|
||||
## References
|
||||
* [https://ssbc.github.io/scuttlebutt-protocol-guide/](https://ssbc.github.io/scuttlebutt-protocol-guide/)
|
||||
* [https://dev.planetary.social/](https://dev.planetary.social/)
|
||||
* [https://dev.planetary.social/](https://dev.planetary.social/)
|
||||
* [https://dev.scuttlebutt.nz/#/golang/?id=muxrpc-endpoints](https://dev.scuttlebutt.nz/#/golang/?id=muxrpc-endpoints)
|
@ -21,7 +21,7 @@ In combines the following key components:
|
||||
are mediated through the core process.
|
||||
|
||||
When run with no arguments, it starts a web server on
|
||||
[http://localhost:12345/](http://localhost:12345/) and an SSB server.
|
||||
[http://localhost:12345/](http://localhost:12345/) and an SSB node.
|
||||
|
||||
## Web Interface
|
||||
The Tilde Friends web server provides access to Tilde Friends applications,
|
@ -5,8 +5,6 @@
|
||||
- Sync status (problem feeds, messages/seconds stats, ...)
|
||||
- app: wiki
|
||||
- app: public blog
|
||||
- app: build archive
|
||||
- app: todo
|
||||
- Content-Disposition: download
|
||||
- remove SSB credentials
|
||||
- export SSB credentials
|
||||
@ -18,26 +16,25 @@
|
||||
- / => Something good.
|
||||
- update docs
|
||||
- audit + document API exposed to apps
|
||||
- sqlStream => sqlExec or something
|
||||
- fix weird HTTP warnings
|
||||
- ssb from child process?
|
||||
- channels
|
||||
- image downsample
|
||||
- placeholder/missing images
|
||||
- no denial of service
|
||||
- package standalone executable
|
||||
- blob_wants 2.0
|
||||
- editor without app iframe
|
||||
- sequence_before_author -> flags
|
||||
- linkify ssb: links
|
||||
|
||||
## MVP2
|
||||
- perfect rooms support
|
||||
- connections 2.0
|
||||
- make a better connections API
|
||||
|
||||
## Maybe Done
|
||||
- blob_wants 2.0
|
||||
- image downsample
|
||||
- app: todo
|
||||
- app: build archive
|
||||
- update README
|
||||
- administrators config
|
||||
- administrators config
|
||||
- apps name characters
|
||||
- initial: can't switch to account when there is only one
|
||||
- get tarball under 5MB
|
||||
@ -58,7 +55,9 @@
|
||||
- keep working on good error feedback
|
||||
- build for windows
|
||||
- installable apps (bring back an app message?)
|
||||
- sqlStream => sqlExec or something
|
||||
- !ssb from child process?
|
||||
|
||||
## Done
|
||||
- update LICENSE
|
||||
- logging to browser
|
||||
- logging to browser
|
62
apps/docs/vision.md
Normal file
62
apps/docs/vision.md
Normal file
@ -0,0 +1,62 @@
|
||||
# Tilde Friends Vision
|
||||
[Back to index](#index)
|
||||
|
||||
Tilde Friends is a tool for making and sharing.
|
||||
|
||||
It is both a peer-to-peer social network client, participating in Secure
|
||||
Scuttlebutt, and an environment for creating and running web applications.
|
||||
|
||||
## Why
|
||||
|
||||
This is a thing that I wanted to exist and wanted to work on. No other reason.
|
||||
There is not a business model. I believe it is interesting and unique.
|
||||
|
||||
## Goals
|
||||
1. Make it **easy and fun** to run all sorts of web applications.
|
||||
|
||||
2. Provide **security** that is easy to understand and protects your data.
|
||||
|
||||
3. Make **creating and sharing** web applications accessible to anyone with a
|
||||
browser.
|
||||
|
||||
## Ways to Use Tilde Friends
|
||||
1. **Social Network User**: This is a social network first. You are just here,
|
||||
because your friends are. Or you like how we limit your message length or
|
||||
short videos or whatever the trend is. If you are ambitious, you click links
|
||||
and see interactive experiences (apps) that you wouldn't see elsewhere.
|
||||
|
||||
2. **Web Visitor**: You get links from a friend to meeting invites, polls, games,
|
||||
lists, wiki pages, ..., and you interact with them as though they were
|
||||
cloud-hosted by a megacorporation. They just work, and you don't think twice.
|
||||
|
||||
3. **Group leader**: You host or use a small public instance, installing apps for
|
||||
a group of friends to use as web visitors.
|
||||
|
||||
4. **Developer**: You like to write code and make or improve apps for fun or to
|
||||
solve problems. When you encounter a Tilde Friends app on a strange server,
|
||||
you know you can trivially modify it or download it to your own instance.
|
||||
|
||||
## Future Goals / Endgame
|
||||
1. Mobile apps. This can run on your old phone. Maybe you won't be hosting
|
||||
the web interface publicly, but you can sync, install and edit apps, and
|
||||
otherwise get the full experience from a tiny touch screen.
|
||||
|
||||
2. The universal application runtime. The web browser is the universal
|
||||
platform, but even for the simplest application that you might want to host
|
||||
for your friends, cloud hosting, containers, and complicated dependencies might
|
||||
all enter the mix. Tilde Friends, though it is yet another thing to host,
|
||||
includes everything you need out of the box to run a vast variety of interesting
|
||||
apps.
|
||||
|
||||
Tilde Friends will be built out, gradually providing safe access to host
|
||||
resources and client resources the same way web browsers extended access to
|
||||
resources like GPU, persistent storage, cameras, ... over the years.
|
||||
|
||||
Not much effort has been put forward yet to having a robust, long-lasting API,
|
||||
but since the client side longevity is already handled by web browsers, it
|
||||
seems possible that the server-side API can be managed in a similar way.
|
||||
|
||||
3. An awesome development environment. Right now it runs JavaScript from the
|
||||
first embeddable text editor I could poorly configure enough to edit code,
|
||||
but it could incorporate a debugger, source control integration a la ssb-git,
|
||||
merge tools, and transpiling from all sorts of different languages.
|
4
apps/follow.json
Normal file
4
apps/follow.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "➡️"
|
||||
}
|
157
apps/follow/app.js
Normal file
157
apps/follow/app.js
Normal file
@ -0,0 +1,157 @@
|
||||
var g_following_cache = {};
|
||||
var g_following_deep_cache = {};
|
||||
var g_about_cache = {};
|
||||
|
||||
async function following(db, id) {
|
||||
if (g_following_cache[id]) {
|
||||
return g_following_cache[id];
|
||||
}
|
||||
var o = await db.get(id + ":following");
|
||||
const k_version = 5;
|
||||
var f = o ? JSON.parse(o) : o;
|
||||
if (!f || f.version != k_version) {
|
||||
f = {users: [], sequence: 0, version: k_version};
|
||||
}
|
||||
f.users = new Set(f.users);
|
||||
await ssb.sqlAsync(
|
||||
"SELECT "+
|
||||
" sequence, "+
|
||||
" json_extract(content, '$.contact') AS contact, "+
|
||||
" json_extract(content, '$.following') AS following "+
|
||||
"FROM messages "+
|
||||
"WHERE "+
|
||||
" author = ?1 AND "+
|
||||
" sequence > ?2 AND "+
|
||||
" json_extract(content, '$.type') = 'contact' "+
|
||||
"UNION SELECT MAX(sequence) AS sequence, NULL, NULL FROM messages WHERE author = ?1 "+
|
||||
"ORDER BY sequence",
|
||||
[id, f.sequence],
|
||||
function(row) {
|
||||
if (row.following) {
|
||||
f.users.add(row.contact);
|
||||
} else {
|
||||
f.users.delete(row.contact);
|
||||
}
|
||||
f.sequence = row.sequence;
|
||||
});
|
||||
var as_set = f.users;
|
||||
f.users = Array.from(f.users).sort();
|
||||
var j = JSON.stringify(f);
|
||||
if (o != j) {
|
||||
await db.set(id + ":following", j);
|
||||
}
|
||||
f.users = as_set;
|
||||
g_following_cache[id] = f.users;
|
||||
return f.users;
|
||||
}
|
||||
|
||||
async function followingDeep(db, seed_ids, depth) {
|
||||
if (depth <= 0) {
|
||||
return seed_ids;
|
||||
}
|
||||
var key = JSON.stringify([seed_ids, depth]);
|
||||
if (g_following_deep_cache[key]) {
|
||||
return g_following_deep_cache[key];
|
||||
}
|
||||
var f = await Promise.all(seed_ids.map(x => following(db, x).then(x => [...x])));
|
||||
var ids = [].concat(...f);
|
||||
var x = await followingDeep(db, [...new Set(ids)].sort(), depth - 1);
|
||||
x = [...new Set([].concat(...x, ...seed_ids))].sort();
|
||||
g_following_deep_cache[key] = x;
|
||||
return x;
|
||||
}
|
||||
|
||||
async function getAbout(db, id) {
|
||||
if (g_about_cache[id]) {
|
||||
return g_about_cache[id];
|
||||
}
|
||||
var o = await db.get(id + ":about");
|
||||
const k_version = 4;
|
||||
var f = o ? JSON.parse(o) : o;
|
||||
if (!f || f.version != k_version) {
|
||||
f = {about: {}, sequence: 0, version: k_version};
|
||||
}
|
||||
await ssb.sqlAsync(
|
||||
"SELECT "+
|
||||
" sequence, "+
|
||||
" content "+
|
||||
"FROM messages "+
|
||||
"WHERE "+
|
||||
" author = ?1 AND "+
|
||||
" sequence > ?2 AND "+
|
||||
" json_extract(content, '$.type') = 'about' AND "+
|
||||
" json_extract(content, '$.about') = ?1 "+
|
||||
"UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 "+
|
||||
"ORDER BY sequence",
|
||||
[id, f.sequence],
|
||||
function(row) {
|
||||
f.sequence = row.sequence;
|
||||
if (row.content) {
|
||||
var about = {};
|
||||
try {
|
||||
about = JSON.parse(row.content);
|
||||
} catch {
|
||||
}
|
||||
delete about.about;
|
||||
delete about.type;
|
||||
f.about = Object.assign(f.about, about);
|
||||
}
|
||||
});
|
||||
var j = JSON.stringify(f);
|
||||
if (o != j) {
|
||||
await db.set(id + ":about", j);
|
||||
}
|
||||
g_about_cache[id] = f.about;
|
||||
return f.about;
|
||||
}
|
||||
|
||||
async function getSize(db, id) {
|
||||
let size = 0;
|
||||
await ssb.sqlAsync(
|
||||
"SELECT (SUM(LENGTH(content)) + SUM(LENGTH(author)) + SUM(LENGTH(id))) AS size FROM messages WHERE author = ?1",
|
||||
[id],
|
||||
function (row) {
|
||||
size += row.size;
|
||||
});
|
||||
return size;
|
||||
}
|
||||
|
||||
function niceSize(bytes) {
|
||||
let value = bytes;
|
||||
let unit = 'B';
|
||||
const k_units = ['kB', 'MB', 'GB', 'TB'];
|
||||
for (let u of k_units) {
|
||||
if (value >= 1024) {
|
||||
value /= 1024;
|
||||
unit = u;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Math.round(value * 10) / 10 + ' ' + unit;
|
||||
}
|
||||
|
||||
async function buildTree(db, root, indent, depth) {
|
||||
var f = await following(db, root);
|
||||
var result = indent + '[' + f.size + '] ' + '<a target="_top" href="../index/#' + root + '">' + ((await getAbout(db, root)).name || root) + '</a> ' + niceSize(await getSize(db, root)) + '\n';
|
||||
if (depth > 0) {
|
||||
for (let next of f) {
|
||||
result += await buildTree(db, next, indent + ' ', depth - 1);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
await app.setDocument('<pre style="color: #fff">building...</pre>');
|
||||
var db = await database('ssb');
|
||||
var whoami = await ssb.getIdentities();
|
||||
var tree = '';
|
||||
for (let id of whoami) {
|
||||
await app.setDocument(`<pre style="color: #fff">building... ${id}</pre>`);
|
||||
tree += await buildTree(db, id, '', 2);
|
||||
}
|
||||
await app.setDocument('<pre style="color: #fff">FOLLOWING:\n' + tree + '</pre>');
|
||||
}
|
||||
|
||||
main();
|
4
apps/sneaker.json
Normal file
4
apps/sneaker.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "👟"
|
||||
}
|
30
apps/sneaker/app.js
Normal file
30
apps/sneaker/app.js
Normal file
@ -0,0 +1,30 @@
|
||||
import * as tfrpc from '/tfrpc.js';
|
||||
|
||||
tfrpc.register(async function getAllIdentities() {
|
||||
return ssb.getAllIdentities();
|
||||
});
|
||||
tfrpc.register(async function query(sql, args) {
|
||||
let result = [];
|
||||
await ssb.sqlAsync(sql, args, function callback(row) {
|
||||
result.push(row);
|
||||
});
|
||||
return result;
|
||||
});
|
||||
|
||||
tfrpc.register(async function store_blob(blob) {
|
||||
if (Array.isArray(blob)) {
|
||||
blob = Uint8Array.from(blob);
|
||||
}
|
||||
return await ssb.blobStore(blob);
|
||||
});
|
||||
tfrpc.register(async function get_blob(id) {
|
||||
return Array.from(new Uint8Array(await ssb.blobGet(id)));
|
||||
});
|
||||
tfrpc.register(async function store_message(message) {
|
||||
return await ssb.storeMessage(message);
|
||||
});
|
||||
|
||||
async function main() {
|
||||
await app.setDocument(utf8Decode(await getFile('index.html')));
|
||||
}
|
||||
main();
|
3
apps/sneaker/filesaver.min.js
vendored
Normal file
3
apps/sneaker/filesaver.min.js
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
(function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)});
|
||||
|
||||
//# sourceMappingURL=FileSaver.min.js.map
|
14
apps/sneaker/index.html
Normal file
14
apps/sneaker/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html style="color: #fff">
|
||||
<head>
|
||||
<title>Tilde Friends</title>
|
||||
<base target="_top">
|
||||
</head>
|
||||
<body>
|
||||
<tf-sneaker-app/>
|
||||
<script>window.litDisableBundleWarning = true;</script>
|
||||
<script src="filesaver.min.js"></script>
|
||||
<script src="jszip.min.js"></script>
|
||||
<script src="script.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
13
apps/sneaker/jszip.min.js
vendored
Normal file
13
apps/sneaker/jszip.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
126
apps/sneaker/lit-all.min.js
vendored
Normal file
126
apps/sneaker/lit-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
apps/sneaker/lit-all.min.js.map
Normal file
1
apps/sneaker/lit-all.min.js.map
Normal file
File diff suppressed because one or more lines are too long
226
apps/sneaker/script.js
Normal file
226
apps/sneaker/script.js
Normal file
@ -0,0 +1,226 @@
|
||||
import {LitElement, html} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
|
||||
class TfSneakerAppElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
feeds: {type: Object},
|
||||
progress: {type: Object},
|
||||
result: {type: String},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.feeds = [];
|
||||
this.progress = undefined;
|
||||
this.result = undefined;
|
||||
}
|
||||
|
||||
async search() {
|
||||
let q = this.renderRoot.getElementById('search').value;
|
||||
let result = await tfrpc.rpc.query(`
|
||||
SELECT messages.author AS id, json_extract(messages.content, '$.name') AS name
|
||||
FROM messages_fts(?)
|
||||
JOIN messages ON messages.rowid = messages_fts.rowid
|
||||
WHERE
|
||||
json_extract(messages.content, '$.type') = 'about' AND
|
||||
json_extract(messages.content, '$.about') = messages.author AND
|
||||
json_extract(messages.content, '$.name') IS NOT NULL
|
||||
GROUP BY messages.author
|
||||
HAVING MAX(messages.sequence)
|
||||
ORDER BY COUNT(*) DESC
|
||||
`,
|
||||
[`"${q.replaceAll('"', '""')}"`]);
|
||||
this.feeds = Object.fromEntries(result.map(x => [x.id, x.name]));
|
||||
}
|
||||
|
||||
format_message(message) {
|
||||
let out = {
|
||||
previous: message.previous ?? null,
|
||||
};
|
||||
if (message.sequence_before_author) {
|
||||
out.sequence = message.sequence;
|
||||
out.author = message.author;
|
||||
} else {
|
||||
out.author = message.author;
|
||||
out.sequence = message.sequence;
|
||||
}
|
||||
out.timestamp = message.timestamp;
|
||||
out.hash = message.hash;
|
||||
out.content = JSON.parse(message.content);
|
||||
out.signature = message.signature;
|
||||
return {key: message.id, value: out};
|
||||
}
|
||||
|
||||
sanitize(value) {
|
||||
return value.replaceAll('/', '_').replaceAll('+', '-');
|
||||
}
|
||||
|
||||
guess_ext(data) {
|
||||
function startsWith(prefix) {
|
||||
if (data.length < prefix.length) {
|
||||
return false;
|
||||
}
|
||||
for (let i = 0; i < prefix.length; i++) {
|
||||
if (prefix[i] !== null && data[i] !== prefix[i]) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (startsWith(data, [0xff, 0xd8, 0xff, 0xdb]) ||
|
||||
startsWith(data, [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]) ||
|
||||
startsWith(data, [0xff, 0xd8, 0xff, 0xee]) ||
|
||||
startsWith(data, [0xff, 0xd8, 0xff, 0xe1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00])) {
|
||||
return '.jpg';
|
||||
} else if (startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
||||
return '.png';
|
||||
} else if (startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
|
||||
startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) {
|
||||
return '.gif';
|
||||
} else if (startsWith(data, [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50])) {
|
||||
return '.webp';
|
||||
} else if (startsWith(data, [0x3c, 0x73, 0x76, 0x67])) {
|
||||
return '.svg';
|
||||
} else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
|
||||
return '.mp3';
|
||||
} else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]) ||
|
||||
startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
|
||||
return '.mp4';
|
||||
} else {
|
||||
return '.bin';
|
||||
}
|
||||
}
|
||||
|
||||
async export(id) {
|
||||
let all_messages = '';
|
||||
let sequence = -1;
|
||||
let messages_done = 0;
|
||||
let messages_max = (await tfrpc.rpc.query('SELECT MAX(sequence) AS total FROM messages WHERE author = ?', [id]))[0].total;
|
||||
while (true) {
|
||||
let messages = await tfrpc.rpc.query(
|
||||
'SELECT * FROM messages WHERE author = ? AND SEQUENCE > ? ORDER BY sequence LIMIT 100',
|
||||
[id, sequence]
|
||||
);
|
||||
if (messages?.length) {
|
||||
all_messages += messages.map(x => JSON.stringify(this.format_message(x))).join('\n') + '\n';
|
||||
sequence = messages[messages.length - 1].sequence;
|
||||
messages_done += messages.length;
|
||||
this.progress = {name: 'messages', value: messages_done, max: messages_max};
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
let zip = new JSZip();
|
||||
zip.file(`message/classic/${this.sanitize(id)}.ndjson`, all_messages);
|
||||
|
||||
let blobs = await tfrpc.rpc.query(
|
||||
`SELECT blobs.id
|
||||
FROM messages
|
||||
JOIN messages_refs ON messages.id = messages_refs.message
|
||||
JOIN blobs ON messages_refs.ref = blobs.id
|
||||
WHERE messages.author = ?`,
|
||||
[id]);
|
||||
let blobs_done = 0;
|
||||
for (let row of blobs) {
|
||||
this.progress = {name: 'blobs', value: blobs_done, max: blobs.length};
|
||||
let blob = await tfrpc.rpc.get_blob(row.id);
|
||||
zip.file(`blob/classic/${this.sanitize(row.id)}${this.guess_ext(blob)}`, new Uint8Array(blob));
|
||||
blobs_done++;
|
||||
}
|
||||
|
||||
this.progress = {name: 'saving'};
|
||||
let blob = await zip.generateAsync({type: 'blob'});
|
||||
saveAs(blob, `${this.sanitize(id)}.zip`);
|
||||
this.progress = null;
|
||||
}
|
||||
|
||||
keypress(event) {
|
||||
if (event.key == 'Enter') {
|
||||
this.search();
|
||||
}
|
||||
}
|
||||
|
||||
async import(event) {
|
||||
let file = event.target.files[0];
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
|
||||
this.progress = {name: 'loading'};
|
||||
let zip = new JSZip();
|
||||
file = await zip.loadAsync(file);
|
||||
let messages = [];
|
||||
let blobs = [];
|
||||
file.forEach(function(path, entry) {
|
||||
if (!entry.dir) {
|
||||
if (path.startsWith('message/classic/')) {
|
||||
messages.push(entry);
|
||||
} else {
|
||||
blobs.push(entry);
|
||||
}
|
||||
}
|
||||
});
|
||||
let success = {messages: 0, blobs: 0};
|
||||
let progress = 0;
|
||||
let total_messages = 0;
|
||||
for (let entry of messages) {
|
||||
let lines = (await entry.async('string')).split('\n');
|
||||
total_messages += lines.length;
|
||||
for (let line of lines) {
|
||||
if (!line.length) {
|
||||
continue;
|
||||
}
|
||||
let message = JSON.parse(line);
|
||||
this.progress = {name: 'messages', value: progress++, max: total_messages};
|
||||
if (await tfrpc.rpc.store_message(message.value)) {
|
||||
success.messages++;
|
||||
}
|
||||
}
|
||||
}
|
||||
progress = 0;
|
||||
for (let blob of blobs) {
|
||||
this.progress = {name: 'blobs', value: progress++, max: blobs.length};
|
||||
if (await tfrpc.rpc.store_blob(await blob.async('arraybuffer'))) {
|
||||
success.blobs++;
|
||||
}
|
||||
}
|
||||
this.progress = undefined;
|
||||
this.result = `imported ${success.messages} messages and ${success.blobs} blobs`;
|
||||
}
|
||||
|
||||
render() {
|
||||
let progress;
|
||||
if (this.progress) {
|
||||
if (this.progress.max) {
|
||||
progress = html`<div><label for="progress">${this.progress.name}</label><progress value=${this.progress.value} max=${this.progress.max}></progress></div>`;
|
||||
} else {
|
||||
progress = html`<div><span>${this.progress.name}</span></div>`;
|
||||
}
|
||||
}
|
||||
return html`<h1>SSB 👟net</h1>
|
||||
<code>${this.result}</code>
|
||||
${progress}
|
||||
|
||||
<h2>Import</h2>
|
||||
<input type="file" id="import" @change=${this.import}></input>
|
||||
|
||||
<h2>Export</h2>
|
||||
<input type="text" id="search" @keypress=${this.keypress}></input>
|
||||
<input type="button" value="Search Users" @click=${this.search}></input>
|
||||
<ul>
|
||||
${Object.entries(this.feeds).map(([id, name]) => html`
|
||||
<li>
|
||||
${this.progress ? undefined : html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`}
|
||||
${name}
|
||||
<code style="color: #ccc">${id}</code>
|
||||
</li>
|
||||
`)}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('tf-sneaker-app', TfSneakerAppElement);
|
4
apps/ssb.json
Normal file
4
apps/ssb.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "🐌"
|
||||
}
|
105
apps/ssb/app.js
Normal file
105
apps/ssb/app.js
Normal file
@ -0,0 +1,105 @@
|
||||
import * as tfrpc from '/tfrpc.js';
|
||||
|
||||
let g_database;
|
||||
let g_hash;
|
||||
|
||||
tfrpc.register(async function localStorageGet(key) {
|
||||
return app.localStorageGet(key);
|
||||
});
|
||||
tfrpc.register(async function localStorageSet(key, value) {
|
||||
return app.localStorageSet(key, value);
|
||||
});
|
||||
tfrpc.register(async function databaseGet(key) {
|
||||
return g_database ? g_database.get(key) : undefined;
|
||||
});
|
||||
tfrpc.register(async function databaseSet(key, value) {
|
||||
return g_database ? g_database.set(key, value) : undefined;
|
||||
});
|
||||
tfrpc.register(async function createIdentity() {
|
||||
return ssb.createIdentity();
|
||||
});
|
||||
tfrpc.register(async function getIdentities() {
|
||||
return ssb.getIdentities();
|
||||
});
|
||||
tfrpc.register(async function getAllIdentities() {
|
||||
return ssb.getAllIdentities();
|
||||
});
|
||||
tfrpc.register(async function getBroadcasts() {
|
||||
return ssb.getBroadcasts();
|
||||
});
|
||||
tfrpc.register(async function getConnections() {
|
||||
return ssb.connections();
|
||||
});
|
||||
tfrpc.register(async function getStoredConnections() {
|
||||
return ssb.storedConnections();
|
||||
});
|
||||
tfrpc.register(async function forgetStoredConnection(connection) {
|
||||
return ssb.forgetStoredConnection(connection);
|
||||
});
|
||||
tfrpc.register(async function createTunnel(portal, target) {
|
||||
return ssb.createTunnel(portal, target);
|
||||
});
|
||||
tfrpc.register(async function connect(token) {
|
||||
await ssb.connect(token);
|
||||
});
|
||||
tfrpc.register(async function closeConnection(id) {
|
||||
await ssb.closeConnection(id);
|
||||
});
|
||||
tfrpc.register(async function query(sql, args) {
|
||||
let result = [];
|
||||
await ssb.sqlAsync(sql, args, function callback(row) {
|
||||
result.push(row);
|
||||
});
|
||||
return result;
|
||||
});
|
||||
tfrpc.register(async function appendMessage(id, message) {
|
||||
return ssb.appendMessageWithIdentity(id, message);
|
||||
});
|
||||
core.register('message', async function message_handler(message) {
|
||||
if (message.event == 'hashChange') {
|
||||
g_hash = message.hash;
|
||||
await tfrpc.rpc.hashChanged(message.hash);
|
||||
}
|
||||
});
|
||||
tfrpc.register(function getHash(id, message) {
|
||||
return g_hash;
|
||||
});
|
||||
tfrpc.register(function setHash(hash) {
|
||||
return app.setHash(hash);
|
||||
});
|
||||
ssb.addEventListener('message', async function(id) {
|
||||
await tfrpc.rpc.notifyNewMessage(id);
|
||||
});
|
||||
tfrpc.register(async function store_blob(blob) {
|
||||
if (Array.isArray(blob)) {
|
||||
blob = Uint8Array.from(blob);
|
||||
}
|
||||
return await ssb.blobStore(blob);
|
||||
});
|
||||
tfrpc.register(async function get_blob(id) {
|
||||
return utf8Decode(await ssb.blobGet(id));
|
||||
});
|
||||
tfrpc.register(async function store_message(message) {
|
||||
return await ssb.storeMessage(message);
|
||||
});
|
||||
tfrpc.register(function apps() {
|
||||
return core.apps();
|
||||
});
|
||||
tfrpc.register(async function try_decrypt(id, content) {
|
||||
return await ssb.privateMessageDecrypt(id, content);
|
||||
});
|
||||
ssb.addEventListener('broadcasts', async function() {
|
||||
await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts());
|
||||
});
|
||||
|
||||
core.register('onConnectionsChanged', async function() {
|
||||
await tfrpc.rpc.set('connections', await ssb.connections());
|
||||
});
|
||||
|
||||
async function main() {
|
||||
if (typeof(database) !== 'undefined') {
|
||||
g_database = await database('ssb');
|
||||
}
|
||||
await app.setDocument(utf8Decode(await getFile('index.html')));
|
||||
}
|
||||
main();
|
@ -39,7 +39,7 @@ function splitMatches(text, regexp) {
|
||||
return result;
|
||||
}
|
||||
|
||||
const regex = new RegExp("#[\\w-]+");
|
||||
const regex = new RegExp("(?<!\w)#[\\w-]+");
|
||||
|
||||
function split(textNodes) {
|
||||
const text = textNodes.map(n => n.literal).join("");
|
112
apps/ssb/emojis.js
Normal file
112
apps/ssb/emojis.js
Normal file
@ -0,0 +1,112 @@
|
||||
let g_emojis;
|
||||
|
||||
function get_emojis() {
|
||||
if (g_emojis) {
|
||||
return Promise.resolve(g_emojis);
|
||||
}
|
||||
return fetch('emojis.json').then(function(result) {
|
||||
g_emojis = result.json();
|
||||
return g_emojis;
|
||||
});
|
||||
}
|
||||
|
||||
export function picker(callback, anchor) {
|
||||
get_emojis().then(function(json) {
|
||||
let div = document.createElement('div');
|
||||
div.id = 'emoji_picker';
|
||||
div.style.color = '#000';
|
||||
div.style.background = '#fff';
|
||||
div.style.border = '1px solid #000';
|
||||
div.style.display = 'block';
|
||||
div.style.position = 'absolute';
|
||||
div.style.minWidth = 'min(16em, 90vw)';
|
||||
div.style.width = 'min(16em, 90vw)';
|
||||
div.style.maxWidth = 'min(16em, 90vw)';
|
||||
div.style.maxHeight = '16em';
|
||||
div.style.overflow = 'scroll';
|
||||
div.style.fontWeight = 'bold';
|
||||
div.style.fontSize = 'xx-large';
|
||||
let input = document.createElement('input');
|
||||
input.type = 'text';
|
||||
input.style.display = 'block';
|
||||
input.style.boxSizing = 'border-box';
|
||||
input.style.width = '100%';
|
||||
input.style.margin = '0';
|
||||
input.style.position = 'relative';
|
||||
div.appendChild(input);
|
||||
let list = document.createElement('div');
|
||||
div.appendChild(list);
|
||||
div.addEventListener('mousedown', function(event) {
|
||||
event.stopPropagation();
|
||||
});
|
||||
|
||||
function cleanup() {
|
||||
console.log('emoji cleanup');
|
||||
div.parentElement.removeChild(div);
|
||||
window.removeEventListener('keydown', key_down);
|
||||
console.log('removing click');
|
||||
document.body.removeEventListener('mousedown', cleanup);
|
||||
}
|
||||
|
||||
function key_down(event) {
|
||||
if (event.key == 'Escape') {
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function chosen(event) {
|
||||
console.log(event.srcElement.innerText);
|
||||
callback(event.srcElement.innerText);
|
||||
cleanup();
|
||||
}
|
||||
|
||||
function refresh() {
|
||||
while (list.firstChild) {
|
||||
list.removeChild(list.firstChild);
|
||||
}
|
||||
let search = input.value;
|
||||
let any_at_all = false;
|
||||
for (let row of Object.entries(json)) {
|
||||
let header = document.createElement('div');
|
||||
header.appendChild(document.createTextNode(row[0]));
|
||||
list.appendChild(header);
|
||||
let any = false;
|
||||
for (let entry of Object.entries(row[1])) {
|
||||
if (search &&
|
||||
search.length &&
|
||||
entry[0].indexOf(search) == -1) {
|
||||
continue;
|
||||
}
|
||||
let emoji = document.createElement('span');
|
||||
const k_size = '1.25em';
|
||||
emoji.style.display = 'inline-block';
|
||||
emoji.style.overflow = 'hidden';
|
||||
emoji.style.cursor = 'pointer';
|
||||
emoji.onclick = chosen;
|
||||
emoji.title = entry[0];
|
||||
emoji.appendChild(document.createTextNode(entry[1]));
|
||||
list.appendChild(emoji);
|
||||
any = true;
|
||||
any_at_all = true;
|
||||
}
|
||||
if (!any) {
|
||||
list.removeChild(header);
|
||||
}
|
||||
}
|
||||
if (!any_at_all) {
|
||||
list.appendChild(document.createTextNode('No matches found.'));
|
||||
}
|
||||
}
|
||||
refresh();
|
||||
input.oninput = refresh;
|
||||
document.body.appendChild(div);
|
||||
div.style.position = 'fixed';
|
||||
div.style.top = '50%';
|
||||
div.style.left = '50%';
|
||||
div.style.transform = 'translate(-50%, -50%)';
|
||||
input.focus();
|
||||
console.log('adding click');
|
||||
document.body.addEventListener('mousedown', cleanup);
|
||||
window.addEventListener('keydown', key_down);
|
||||
});
|
||||
}
|
1
apps/ssb/emojis.json
Normal file
1
apps/ssb/emojis.json
Normal file
File diff suppressed because one or more lines are too long
3
apps/ssb/filesaver.min.js
vendored
Normal file
3
apps/ssb/filesaver.min.js
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
(function(a,b){if("function"==typeof define&&define.amd)define([],b);else if("undefined"!=typeof exports)b();else{b(),a.FileSaver={exports:{}}.exports}})(this,function(){"use strict";function b(a,b){return"undefined"==typeof b?b={autoBom:!1}:"object"!=typeof b&&(console.warn("Deprecated: Expected third argument to be a object"),b={autoBom:!b}),b.autoBom&&/^\s*(?:text\/\S*|application\/xml|\S*\/\S*\+xml)\s*;.*charset\s*=\s*utf-8/i.test(a.type)?new Blob(["\uFEFF",a],{type:a.type}):a}function c(a,b,c){var d=new XMLHttpRequest;d.open("GET",a),d.responseType="blob",d.onload=function(){g(d.response,b,c)},d.onerror=function(){console.error("could not download file")},d.send()}function d(a){var b=new XMLHttpRequest;b.open("HEAD",a,!1);try{b.send()}catch(a){}return 200<=b.status&&299>=b.status}function e(a){try{a.dispatchEvent(new MouseEvent("click"))}catch(c){var b=document.createEvent("MouseEvents");b.initMouseEvent("click",!0,!0,window,0,0,0,80,20,!1,!1,!1,!1,0,null),a.dispatchEvent(b)}}var f="object"==typeof window&&window.window===window?window:"object"==typeof self&&self.self===self?self:"object"==typeof global&&global.global===global?global:void 0,a=/Macintosh/.test(navigator.userAgent)&&/AppleWebKit/.test(navigator.userAgent)&&!/Safari/.test(navigator.userAgent),g=f.saveAs||("object"!=typeof window||window!==f?function(){}:"download"in HTMLAnchorElement.prototype&&!a?function(b,g,h){var i=f.URL||f.webkitURL,j=document.createElement("a");g=g||b.name||"download",j.download=g,j.rel="noopener","string"==typeof b?(j.href=b,j.origin===location.origin?e(j):d(j.href)?c(b,g,h):e(j,j.target="_blank")):(j.href=i.createObjectURL(b),setTimeout(function(){i.revokeObjectURL(j.href)},4E4),setTimeout(function(){e(j)},0))}:"msSaveOrOpenBlob"in navigator?function(f,g,h){if(g=g||f.name||"download","string"!=typeof f)navigator.msSaveOrOpenBlob(b(f,h),g);else if(d(f))c(f,g,h);else{var i=document.createElement("a");i.href=f,i.target="_blank",setTimeout(function(){e(i)})}}:function(b,d,e,g){if(g=g||open("","_blank"),g&&(g.document.title=g.document.body.innerText="downloading..."),"string"==typeof b)return c(b,d,e);var h="application/octet-stream"===b.type,i=/constructor/i.test(f.HTMLElement)||f.safari,j=/CriOS\/[\d]+/.test(navigator.userAgent);if((j||h&&i||a)&&"undefined"!=typeof FileReader){var k=new FileReader;k.onloadend=function(){var a=k.result;a=j?a:a.replace(/^data:[^;]*;/,"data:attachment/file;"),g?g.location.href=a:location=a,g=null},k.readAsDataURL(b)}else{var l=f.URL||f.webkitURL,m=l.createObjectURL(b);g?g.location=m:location.href=m,g=null,setTimeout(function(){l.revokeObjectURL(m)},4E4)}});f.saveAs=g.saveAs=g,"undefined"!=typeof module&&(module.exports=g)});
|
||||
|
||||
//# sourceMappingURL=FileSaver.min.js.map
|
22
apps/ssb/index.html
Normal file
22
apps/ssb/index.html
Normal file
@ -0,0 +1,22 @@
|
||||
<!DOCTYPE html>
|
||||
<html style="color: #fff">
|
||||
<head>
|
||||
<title>Tilde Friends</title>
|
||||
<base target="_top">
|
||||
<link rel="stylesheet" href="tribute.css" />
|
||||
<style>
|
||||
.tribute-container {
|
||||
color: #000;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<tf-app/>
|
||||
<script>window.litDisableBundleWarning = true;</script>
|
||||
<script src="filesaver.min.js"></script>
|
||||
<script src="commonmark.min.js"></script>
|
||||
<script src="commonmark-linkify.js" type="module"></script>
|
||||
<script src="commonmark-hashtag.js" type="module"></script>
|
||||
<script src="script.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
126
apps/ssb/lit-all.min.js
vendored
Normal file
126
apps/ssb/lit-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
apps/ssb/lit-all.min.js.map
Normal file
1
apps/ssb/lit-all.min.js.map
Normal file
File diff suppressed because one or more lines are too long
16
apps/ssb/script.js
Normal file
16
apps/ssb/script.js
Normal file
@ -0,0 +1,16 @@
|
||||
import {LitElement, html} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
|
||||
import * as tf_id_picker from './tf-id-picker.js';
|
||||
import * as tf_app from './tf-app.js';
|
||||
import * as tf_message from './tf-message.js';
|
||||
import * as tf_user from './tf-user.js';
|
||||
import * as tf_compose from './tf-compose.js';
|
||||
import * as tf_news from './tf-news.js';
|
||||
import * as tf_profile from './tf-profile.js';
|
||||
import * as tf_tab_mentions from './tf-tab-mentions.js';
|
||||
import * as tf_tab_news from './tf-tab-news.js';
|
||||
import * as tf_tab_news_feed from './tf-tab-news-feed.js';
|
||||
import * as tf_tab_search from './tf-tab-search.js';
|
||||
import * as tf_tab_connections from './tf-tab-connections.js';
|
||||
import * as tf_tag from './tf-tag.js';
|
@ -16,6 +16,7 @@ class TfElement extends LitElement {
|
||||
following: {type: Array},
|
||||
users: {type: Object},
|
||||
ids: {type: Array},
|
||||
tags: {type: Array},
|
||||
};
|
||||
}
|
||||
|
||||
@ -32,8 +33,9 @@ class TfElement extends LitElement {
|
||||
this.following = [];
|
||||
this.users = {};
|
||||
this.loaded = false;
|
||||
tfrpc.rpc.getBroadcasts().then(b => { self.broadcasts = b || [] });
|
||||
tfrpc.rpc.getConnections().then(c => { self.connections = c || [] });
|
||||
this.tags = [];
|
||||
tfrpc.rpc.getBroadcasts().then(b => { self.broadcasts = b || []; });
|
||||
tfrpc.rpc.getConnections().then(c => { self.connections = c || []; });
|
||||
tfrpc.rpc.getHash().then(hash => self.set_hash(hash));
|
||||
tfrpc.register(function hashChanged(hash) {
|
||||
self.set_hash(hash);
|
||||
@ -64,6 +66,8 @@ class TfElement extends LitElement {
|
||||
this.tab = 'search';
|
||||
} else if (this.hash === '#connections') {
|
||||
this.tab = 'connections';
|
||||
} else if (this.hash === '#mentions') {
|
||||
this.tab = 'mentions';
|
||||
} else {
|
||||
this.tab = 'news';
|
||||
}
|
||||
@ -79,7 +83,7 @@ class TfElement extends LitElement {
|
||||
WHERE author = ? AND
|
||||
rowid > ? AND
|
||||
rowid <= ? AND
|
||||
json_extract(content, "$.type") = "contact"
|
||||
json_extract(content, '$.type') = 'contact'
|
||||
ORDER BY sequence
|
||||
`,
|
||||
[id, last_row_id, max_row_id]);
|
||||
@ -109,8 +113,9 @@ class TfElement extends LitElement {
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
let id = ids[i];
|
||||
let contact = contacts[i];
|
||||
let found = Object.keys(contact.following).filter(y => !contact.blocking[y]);
|
||||
let deeper = depth > 1 ? await this.following_deep_internal(found, depth - 1, Object.assign({}, contact.blocking, blocking), last_row_id, following, max_row_id) : [];
|
||||
let all_blocking = Object.assign({}, contact.blocking, blocking);
|
||||
let found = Object.keys(contact.following).filter(y => !all_blocking[y]);
|
||||
let deeper = depth > 1 ? await this.following_deep_internal(found, depth - 1, all_blocking, last_row_id, following, max_row_id) : [];
|
||||
result[id] = [id, ...found, ...deeper];
|
||||
}
|
||||
return [...new Set(Object.values(result).flat())];
|
||||
@ -132,7 +137,11 @@ class TfElement extends LitElement {
|
||||
`, []))[0].max_row_id;
|
||||
let result = await this.following_deep_internal(ids, depth, blocking, cache.last_row_id, cache.following, max_row_id);
|
||||
cache.last_row_id = max_row_id;
|
||||
await tfrpc.rpc.databaseSet('following', JSON.stringify(cache));
|
||||
let store = JSON.stringify(cache);
|
||||
/* 2023-02-20: Exceeding message size. */
|
||||
//if (store.length < 512 * 1024) {
|
||||
await tfrpc.rpc.databaseSet('following', store);
|
||||
//}
|
||||
return [result, cache.following];
|
||||
}
|
||||
|
||||
@ -246,12 +255,34 @@ class TfElement extends LitElement {
|
||||
`;
|
||||
}
|
||||
|
||||
async load_recent_tags() {
|
||||
let start = new Date();
|
||||
this.tags = await tfrpc.rpc.query(`
|
||||
WITH
|
||||
recent AS (SELECT id, content FROM messages
|
||||
WHERE messages.timestamp > ? AND json_extract(content, '$.type') = 'post'
|
||||
ORDER BY timestamp DESC LIMIT 1024),
|
||||
recent_channels AS (SELECT recent.id, '#' || json_extract(content, '$.channel') AS tag
|
||||
FROM recent
|
||||
WHERE json_extract(content, '$.channel') IS NOT NULL),
|
||||
recent_mentions AS (SELECT recent.id, json_extract(mention.value, '$.link') AS tag
|
||||
FROM recent, json_each(recent.content, '$.mentions') AS mention
|
||||
WHERE json_valid(mention.value) AND tag LIKE '#%'),
|
||||
combined AS (SELECT id, tag FROM recent_channels UNION ALL SELECT id, tag FROM recent_mentions),
|
||||
by_message AS (SELECT DISTINCT id, tag FROM combined)
|
||||
SELECT tag, COUNT(*) AS count FROM by_message GROUP BY tag ORDER BY count DESC LIMIT 10
|
||||
`, [new Date() - 7 * 24 * 60 * 60 * 1000]);
|
||||
console.log('tags took', (new Date() - start) / 1000.0, 'seconds');
|
||||
}
|
||||
|
||||
async load() {
|
||||
let whoami = this.whoami;
|
||||
let tags = this.load_recent_tags();
|
||||
let [following, users] = await this.following_deep([whoami], 2, {});
|
||||
users = await this.fetch_about(following.sort(), users);
|
||||
this.following = following;
|
||||
this.users = users;
|
||||
await tags;
|
||||
console.log(`load finished ${whoami} => ${this.whoami}`);
|
||||
this.whoami = whoami;
|
||||
this.loaded = whoami;
|
||||
@ -268,6 +299,10 @@ class TfElement extends LitElement {
|
||||
return html`
|
||||
<tf-tab-connections .users=${this.users} .connections=${this.connections} .broadcasts=${this.broadcasts}></tf-tab-connections>
|
||||
`;
|
||||
} else if (this.tab === 'mentions') {
|
||||
return html`
|
||||
<tf-tab-mentions .following=${this.following} whoami=${this.whoami} .users=${this.users}}></tf-tab-mentions>
|
||||
`;
|
||||
} else if (this.tab === 'search') {
|
||||
return html`
|
||||
<tf-tab-search .following=${this.following} whoami=${this.whoami} .users=${this.users} query=${this.hash?.startsWith('#q=') ? decodeURIComponent(this.hash.substring(3)) : null}></tf-tab-search>
|
||||
@ -275,22 +310,14 @@ class TfElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
add_fake_news() {
|
||||
this.unread = [{
|
||||
author: this.whoami,
|
||||
placeholder: true,
|
||||
id: '%fake_id',
|
||||
text: 'text',
|
||||
content: 'hello',
|
||||
}, ...this.unread];
|
||||
}
|
||||
|
||||
async set_tab(tab) {
|
||||
this.tab = tab;
|
||||
if (tab === 'news') {
|
||||
await tfrpc.rpc.setHash('#');
|
||||
} else if (tab === 'connections') {
|
||||
await tfrpc.rpc.setHash('#connections');
|
||||
} else if (tab === 'mentions') {
|
||||
await tfrpc.rpc.setHash('#mentions');
|
||||
}
|
||||
}
|
||||
|
||||
@ -309,6 +336,7 @@ class TfElement extends LitElement {
|
||||
<div>
|
||||
<input type="button" class="tab" value="News" ?disabled=${self.tab == 'news'} @click=${() => self.set_tab('news')}></input>
|
||||
<input type="button" class="tab" value="Connections" ?disabled=${self.tab == 'connections'} @click=${() => self.set_tab('connections')}></input>
|
||||
<input type="button" class="tab" value="Mentions" ?disabled=${self.tab == 'mentions'} @click=${() => self.set_tab('mentions')}></input>
|
||||
<input type="button" class="tab" value="Search" ?disabled=${self.tab == 'search'} @click=${() => self.set_tab('search')}></input>
|
||||
</div>
|
||||
`;
|
||||
@ -321,7 +349,7 @@ class TfElement extends LitElement {
|
||||
return html`
|
||||
${this.render_id_picker()}
|
||||
${tabs}
|
||||
<!-- <input type="button" value="Fake News" @click=${this.add_fake_news}></input> -->
|
||||
${this.tags.map(x => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>`)}
|
||||
${contents}
|
||||
`;
|
||||
}
|
384
apps/ssb/tf-compose.js
Normal file
384
apps/ssb/tf-compose.js
Normal file
@ -0,0 +1,384 @@
|
||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||
import * as tfutils from './tf-utils.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
import Tribute from './tribute.esm.js';
|
||||
|
||||
class TfComposeElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
users: {type: Object},
|
||||
root: {type: String},
|
||||
branch: {type: String},
|
||||
apps: {type: Object},
|
||||
drafts: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.users = {};
|
||||
this.root = undefined;
|
||||
this.branch = undefined;
|
||||
this.apps = undefined;
|
||||
this.drafts = {};
|
||||
}
|
||||
|
||||
process_text(text) {
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
/* Update mentions. */
|
||||
let draft = this.get_draft();
|
||||
let updated = false;
|
||||
for (let match of text.matchAll(/\[([^\[]+)]\(([@&%][^\)]+)/g)) {
|
||||
let name = match[1];
|
||||
let link = match[2];
|
||||
let balance = 0;
|
||||
let bracket_end = match.index + match[1].length + '[]'.length - 1;
|
||||
for (let i = bracket_end; i >= 0; i--) {
|
||||
if (text.charAt(i) == ']') {
|
||||
balance++;
|
||||
} else if (text.charAt(i) == '[') {
|
||||
balance--;
|
||||
}
|
||||
if (balance <= 0) {
|
||||
name = text.substring(i + 1, bracket_end);
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!draft.mentions) {
|
||||
draft.mentions = {};
|
||||
}
|
||||
if (!draft.mentions[link]) {
|
||||
draft.mentions[link] = {
|
||||
link: link,
|
||||
};
|
||||
}
|
||||
draft.mentions[link].name = name.startsWith('@') ? name.substring(1) : name;
|
||||
updated = true;
|
||||
}
|
||||
if (updated) {
|
||||
this.requestUpdate();
|
||||
}
|
||||
return tfutils.markdown(text);
|
||||
}
|
||||
|
||||
input(event) {
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
let preview = this.renderRoot.getElementById('preview');
|
||||
preview.innerHTML = this.process_text(edit.value);
|
||||
let content_warning = this.renderRoot.getElementById('content_warning');
|
||||
let content_warning_preview = this.renderRoot.getElementById('content_warning_preview');
|
||||
if (content_warning && content_warning_preview) {
|
||||
content_warning_preview.innerText = content_warning.value;
|
||||
}
|
||||
}
|
||||
|
||||
notify(draft) {
|
||||
this.dispatchEvent(new CustomEvent('tf-draft', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
id: this.branch,
|
||||
draft: draft
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
change() {
|
||||
let draft = this.get_draft();
|
||||
draft.text = this.renderRoot.getElementById('edit')?.value;
|
||||
draft.content_warning = this.renderRoot.getElementById('content_warning')?.value;
|
||||
this.notify(draft);
|
||||
}
|
||||
|
||||
convert_to_format(buffer, type, mime_type) {
|
||||
return new Promise(function(resolve, reject) {
|
||||
let img = new Image();
|
||||
img.onload = function() {
|
||||
let canvas = document.createElement('canvas');
|
||||
let width_scale = Math.min(img.width, 1024) / img.width;
|
||||
let height_scale = Math.min(img.height, 1024) / img.height;
|
||||
let scale = Math.min(width_scale, height_scale);
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
let context = canvas.getContext('2d');
|
||||
context.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
let data_url = canvas.toDataURL(mime_type);
|
||||
let result = atob(data_url.split(',')[1]).split('').map(x => x.charCodeAt(0));
|
||||
resolve(result);
|
||||
};
|
||||
img.onerror = function(event) {
|
||||
reject(new Error('Failed to load image.'));
|
||||
};
|
||||
let raw = Array.from(new Uint8Array(buffer)).map(b => String.fromCharCode(b)).join('');
|
||||
let original = `data:${type};base64,${btoa(raw)}`;
|
||||
img.src = original;
|
||||
});
|
||||
}
|
||||
|
||||
async add_file(file) {
|
||||
try {
|
||||
let draft = this.get_draft();
|
||||
let self = this;
|
||||
let buffer = await file.arrayBuffer();
|
||||
let type = file.type;
|
||||
if (type.startsWith('image/')) {
|
||||
let best_buffer;
|
||||
let best_type;
|
||||
for (let format of ['image/png', 'image/jpeg', 'image/webp']) {
|
||||
let test_buffer = await self.convert_to_format(buffer, file.type, format);
|
||||
if (!best_buffer || test_buffer.length < best_buffer.length) {
|
||||
best_buffer = test_buffer;
|
||||
best_type = format;
|
||||
}
|
||||
}
|
||||
buffer = best_buffer;
|
||||
type = best_type;
|
||||
} else {
|
||||
buffer = Array.from(new Uint8Array(buffer));
|
||||
}
|
||||
let id = await tfrpc.rpc.store_blob(buffer);
|
||||
let name = type.split('/')[0] + ':' + file.name;
|
||||
if (!draft.mentions) {
|
||||
draft.mentions = {};
|
||||
}
|
||||
draft.mentions[id] = {
|
||||
link: id,
|
||||
name: name,
|
||||
type: type,
|
||||
size: buffer.length ?? buffer.byteLength,
|
||||
};
|
||||
let edit = self.renderRoot.getElementById('edit');
|
||||
edit.value += `\n`;
|
||||
self.change();
|
||||
self.input();
|
||||
} catch(e) {
|
||||
alert(e?.message);
|
||||
}
|
||||
}
|
||||
|
||||
paste(event) {
|
||||
let self = this;
|
||||
for (let item of event.clipboardData.items) {
|
||||
if (item.type?.startsWith('image/')) {
|
||||
let file = item.getAsFile();
|
||||
if (!file) {
|
||||
continue;
|
||||
}
|
||||
self.add_file(file);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
submit() {
|
||||
let self = this;
|
||||
let draft = this.get_draft();
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
let message = {
|
||||
type: 'post',
|
||||
text: edit.value,
|
||||
};
|
||||
if (this.root || this.branch) {
|
||||
message.root = this.root;
|
||||
message.branch = this.branch;
|
||||
}
|
||||
if (Object.values(draft.mentions || {}).length) {
|
||||
message.mentions = Object.values(draft.mentions);
|
||||
}
|
||||
if (draft.content_warning !== undefined) {
|
||||
message.contentWarning = draft.content_warning;
|
||||
}
|
||||
console.log('Would post:', message);
|
||||
tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
|
||||
edit.value = '';
|
||||
self.change();
|
||||
self.notify(undefined);
|
||||
self.requestUpdate();
|
||||
}).catch(function(error) {
|
||||
alert(error.message);
|
||||
});
|
||||
}
|
||||
|
||||
discard() {
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
edit.value = '';
|
||||
this.change();
|
||||
let preview = this.renderRoot.getElementById('preview');
|
||||
preview.innerHTML = '';
|
||||
this.notify(undefined);
|
||||
}
|
||||
|
||||
attach() {
|
||||
let self = this;
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
let input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.onchange = function(event) {
|
||||
let file = event.target.files[0];
|
||||
self.add_file(file);
|
||||
};
|
||||
input.click();
|
||||
}
|
||||
|
||||
firstUpdated() {
|
||||
let tribute = new Tribute({
|
||||
values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
|
||||
selectTemplate: function(item) {
|
||||
return `[@${item.original.key}](${item.original.value})`;
|
||||
},
|
||||
});
|
||||
tribute.attach(this.renderRoot.getElementById('edit'));
|
||||
}
|
||||
|
||||
updated() {
|
||||
super.updated();
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
if (this.last_updated_text !== edit.value) {
|
||||
let preview = this.renderRoot.getElementById('preview');
|
||||
preview.innerHTML = this.process_text(edit.value);
|
||||
this.last_updated_text = edit.value;
|
||||
}
|
||||
}
|
||||
|
||||
remove_mention(id) {
|
||||
let draft = this.get_draft();
|
||||
delete draft.mentions[id];
|
||||
this.notify(draft);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render_mention(mention) {
|
||||
let self = this;
|
||||
return html`
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<div style="align-self: center; margin: 0.5em">
|
||||
<input type="button" value="🚮" title="Remove ${mention.name} mention" @click=${() => self.remove_mention(mention.link)}></input>
|
||||
</div>
|
||||
<div style="display: flex; flex-direction: column">
|
||||
<h3>${mention.name}</h3>
|
||||
<div style="padding-left: 1em">
|
||||
${Object.entries(mention)
|
||||
.filter(x => x[0] != 'name')
|
||||
.map(x => html`<div><span style="font-weight: bold">${x[0]}</span>: ${x[1]}</div>`)}
|
||||
</div>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
render_attach_app() {
|
||||
let self = this;
|
||||
|
||||
async function attach_selected_app() {
|
||||
let name = self.renderRoot.getElementById('select').value;
|
||||
let id = self.apps[name];
|
||||
let mentions = {};
|
||||
mentions[id] = {
|
||||
name: name,
|
||||
link: id,
|
||||
type: 'application/tildefriends',
|
||||
};
|
||||
if (name && id) {
|
||||
let app = JSON.parse(await tfrpc.rpc.get_blob(id));
|
||||
for (let entry of Object.entries(app.files)) {
|
||||
mentions[entry[1]] = {
|
||||
name: entry[0],
|
||||
link: entry[1],
|
||||
};
|
||||
}
|
||||
}
|
||||
let draft = self.get_draft();
|
||||
draft.mentions = Object.assign(draft.mentions || {}, mentions);
|
||||
self.requestUpdate();
|
||||
self.notify(draft);
|
||||
self.apps = null;
|
||||
}
|
||||
|
||||
if (this.apps) {
|
||||
return html`
|
||||
<div>
|
||||
<select id="select">
|
||||
${Object.keys(self.apps).map(app => html`<option value=${app}>${app}</option>`)}
|
||||
</select>
|
||||
<input type="button" value="Attach" @click=${attach_selected_app}></input>
|
||||
<input type="button" value="Cancel" @click=${() => this.apps = null}></input>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
render_attach_app_button() {
|
||||
let self = this;
|
||||
async function attach_app() {
|
||||
self.apps = await tfrpc.rpc.apps();
|
||||
}
|
||||
if (!this.apps) {
|
||||
return html`<input type="button" value="Attach App" @click=${attach_app}></input>`;
|
||||
} else {
|
||||
return html`<input type="button" value="Discard App" @click=${() => this.apps = null}></input>`;
|
||||
}
|
||||
}
|
||||
|
||||
set_content_warning(value) {
|
||||
let draft = this.get_draft();
|
||||
draft.content_warning = value;
|
||||
this.notify(draft);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render_content_warning() {
|
||||
let self = this;
|
||||
let draft = this.get_draft();
|
||||
if (draft.content_warning !== undefined) {
|
||||
return html`
|
||||
<div>
|
||||
<input type="checkbox" id="cw" @change=${() => self.set_content_warning(undefined)} checked></input>
|
||||
<label for="cw">CW</label>
|
||||
<input type="text" id="content_warning" @input=${this.input} @change=${this.change} value=${draft.content_warning}></input>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return html`
|
||||
<input type="checkbox" id="cw" @change=${() => self.set_content_warning('')}></input>
|
||||
<label for="cw">CW</label>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
get_draft() {
|
||||
return this.drafts[this.branch || ''] || {};
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
let draft = self.get_draft();
|
||||
let content_warning =
|
||||
draft.content_warning !== undefined ?
|
||||
html`<div id="content_warning_preview" class="content_warning">${draft.content_warning}</div>` :
|
||||
undefined;
|
||||
let result = html`
|
||||
<div style="display: flex; flex-direction: row; width: 100%">
|
||||
<textarea id="edit" @input=${this.input} @change=${this.change} @paste=${this.paste} style="flex: 1 0 50%">${draft.text}</textarea>
|
||||
<div style="flex: 1 0 50%">
|
||||
${content_warning}
|
||||
<div id="preview"></div>
|
||||
</div>
|
||||
</div>
|
||||
${Object.values(draft.mentions || {}).map(x => self.render_mention(x))}
|
||||
${this.render_content_warning()}
|
||||
${this.render_attach_app()}
|
||||
<input type="button" value="Submit" @click=${this.submit}></input>
|
||||
<input type="button" value="Attach" @click=${this.attach}></input>
|
||||
${this.render_attach_app_button()}
|
||||
<input type="button" value="Discard" @click=${this.discard}></input>
|
||||
`;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-compose', TfComposeElement);
|
@ -9,7 +9,7 @@ class TfIdentityPickerElement extends LitElement {
|
||||
return {
|
||||
ids: {type: Array},
|
||||
selected: {type: String},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
488
apps/ssb/tf-message.js
Normal file
488
apps/ssb/tf-message.js
Normal file
@ -0,0 +1,488 @@
|
||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import * as tfutils from './tf-utils.js';
|
||||
import * as emojis from './emojis.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfMessageElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
message: {type: Object},
|
||||
users: {type: Object},
|
||||
drafts: {type: Object},
|
||||
raw: {type: Boolean},
|
||||
blog_data: {type: String},
|
||||
expanded: {type: Object},
|
||||
decrypted: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.whoami = null;
|
||||
this.message = {};
|
||||
this.users = {};
|
||||
this.drafts = {};
|
||||
this.raw = false;
|
||||
this.expanded = {};
|
||||
this.decrypted = undefined;
|
||||
}
|
||||
|
||||
show_reply() {
|
||||
let event = new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.message?.id, draft: ''}});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
discard_reply() {
|
||||
this.dispatchEvent(new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.id, draft: undefined}}));
|
||||
}
|
||||
|
||||
render_votes() {
|
||||
function normalize_expression(expression) {
|
||||
if (expression === 'Like' || !expression) {
|
||||
return '👍';
|
||||
} else if (expression === 'Unlike') {
|
||||
return '👎';
|
||||
} else if (expression === 'heart') {
|
||||
return '❤️';
|
||||
} else {
|
||||
return expression;
|
||||
}
|
||||
}
|
||||
return html`<div>${(this.message.votes || []).map(
|
||||
vote => html`
|
||||
<span title="${this.users[vote.author]?.name ?? vote.author} ${new Date(vote.timestamp)}">
|
||||
${normalize_expression(vote.content.vote.expression)}
|
||||
</span>
|
||||
`)}</div>`;
|
||||
}
|
||||
|
||||
render_raw() {
|
||||
let raw = {
|
||||
id: this.message?.id,
|
||||
previous: this.message?.previous,
|
||||
author: this.message?.author,
|
||||
sequence: this.message?.sequence,
|
||||
timestamp: this.message?.timestamp,
|
||||
hash: this.message?.hash,
|
||||
content: this.message?.content,
|
||||
signature: this.message?.signature,
|
||||
};
|
||||
return html`<div style="white-space: pre-wrap">${JSON.stringify(raw, null, 2)}</div>`;
|
||||
}
|
||||
|
||||
vote(emoji) {
|
||||
let reaction = emoji;
|
||||
let message = this.message.id;
|
||||
if (confirm('Are you sure you want to react with ' + reaction + ' to ' + message + '?')) {
|
||||
tfrpc.rpc.appendMessage(
|
||||
this.whoami,
|
||||
{
|
||||
type: 'vote',
|
||||
vote: {
|
||||
link: message,
|
||||
value: 1,
|
||||
expression: reaction,
|
||||
},
|
||||
}).catch(function(error) {
|
||||
alert(error?.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
react(event) {
|
||||
emojis.picker(x => this.vote(x));
|
||||
}
|
||||
|
||||
show_image(link) {
|
||||
let div = document.createElement('div');
|
||||
div.style.left = 0;
|
||||
div.style.top = 0;
|
||||
div.style.width = '100%';
|
||||
div.style.height = '100%';
|
||||
div.style.position = 'fixed';
|
||||
div.style.background = '#000';
|
||||
div.style.zIndex = 100;
|
||||
div.style.display = 'grid';
|
||||
let img = document.createElement('img');
|
||||
img.src = link;
|
||||
img.style.maxWidth = '100%';
|
||||
img.style.maxHeight = '100%';
|
||||
img.style.display = 'block';
|
||||
img.style.margin = 'auto';
|
||||
img.style.objectFit = 'contain';
|
||||
img.style.width = '100%';
|
||||
div.appendChild(img);
|
||||
function image_close(event) {
|
||||
document.body.removeChild(div);
|
||||
window.removeEventListener('keydown', image_close);
|
||||
}
|
||||
div.onclick = image_close;
|
||||
window.addEventListener('keydown', image_close);
|
||||
document.body.appendChild(div);
|
||||
}
|
||||
|
||||
body_click(event) {
|
||||
if (event.srcElement.tagName == 'IMG') {
|
||||
this.show_image(event.srcElement.src);
|
||||
} else if (event.srcElement.tagName == 'DIV' && event.srcElement.classList.contains('img_caption')) {
|
||||
let next = event.srcElement.nextSibling;
|
||||
if (next.style.display == 'block') {
|
||||
next.style.display = 'none';
|
||||
} else {
|
||||
next.style.display = 'block';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render_mention(mention) {
|
||||
if (!mention?.link || typeof(mention.link) != 'string') {
|
||||
return html` <pre>${JSON.stringify(mention)}</pre>`;
|
||||
} else if (mention?.link?.startsWith('&') &&
|
||||
mention?.type?.startsWith('image/')) {
|
||||
return html`
|
||||
<img src=${'/' + mention.link + '/view'} style="max-width: 128px; max-height: 128px" title=${mention.name} @click=${() => this.show_image('/' + mention.link + '/view')}>
|
||||
`;
|
||||
} else if (mention.link?.startsWith('&') &&
|
||||
mention.name?.startsWith('audio:')) {
|
||||
return html`
|
||||
<audio controls style="height: 32px">
|
||||
<source src=${'/' + mention.link + '/view'}></source>
|
||||
</audio>
|
||||
`;
|
||||
} else if (mention.link?.startsWith('&') &&
|
||||
mention.name?.startsWith('video:')) {
|
||||
return html`
|
||||
<video controls style="max-height: 240px; max-width: 128px">
|
||||
<source src=${'/' + mention.link + '/view'}></source>
|
||||
</video>
|
||||
`;
|
||||
} else if (mention.link?.startsWith('&') &&
|
||||
mention?.type === 'application/tildefriends') {
|
||||
return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`;
|
||||
} else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) {
|
||||
return html` <a href=${'#' + encodeURIComponent(mention.link)}>${mention.name}</a>`;
|
||||
} else if (mention.link?.startsWith('#')) {
|
||||
return html` <a href=${'#q=' + encodeURIComponent(mention.link)}>${mention.link}</a>`;
|
||||
} else if (Object.keys(mention).length == 2 && mention.link && mention.name) {
|
||||
return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`;
|
||||
} else {
|
||||
return html` <pre style="white-space: pre-wrap">${JSON.stringify(mention, null, 2)}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
render_mentions() {
|
||||
let mentions = this.message?.content?.mentions || [];
|
||||
mentions = mentions.filter(x => this.message?.content?.text?.indexOf(x.link) === -1);
|
||||
if (mentions.length) {
|
||||
let self = this;
|
||||
return html`
|
||||
<fieldset style="background-color: rgba(0, 0, 0, 0.1); padding: 0.5em; border: 1px solid black">
|
||||
<legend>Mentions</legend>
|
||||
${mentions.map(x => self.render_mention(x))}
|
||||
</fieldset>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
total_child_messages(message) {
|
||||
if (!message.child_messages) {
|
||||
return 0;
|
||||
}
|
||||
let total = message.child_messages.length;
|
||||
for (let m of message.child_messages)
|
||||
{
|
||||
total += this.total_child_messages(m);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
set_expanded(expanded, tag) {
|
||||
this.dispatchEvent(new CustomEvent('tf-expand', {bubbles: true, composed: true, detail: {id: (this.message.id || '') + (tag || ''), expanded: expanded}}));
|
||||
}
|
||||
|
||||
toggle_expanded(tag) {
|
||||
this.set_expanded(!this.expanded[(this.message.id || '') + (tag || '')], tag);
|
||||
}
|
||||
|
||||
render_children() {
|
||||
let self = this;
|
||||
if (this.message.child_messages?.length) {
|
||||
if (!this.expanded[this.message.id]) {
|
||||
return html`<input type="button" value=${this.total_child_messages(this.message) + ' More'} @click=${() => self.set_expanded(true)}></input>`;
|
||||
} else {
|
||||
return html`<input type="button" value="Collapse" @click=${() => self.set_expanded(false)}></input>${(this.message.child_messages || []).map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>`)}`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render_channels() {
|
||||
let content = this.message?.content;
|
||||
if (this.decrypted?.type == 'post') {
|
||||
content = this.decrypted;
|
||||
}
|
||||
let channels = [];
|
||||
if (typeof content.channel === 'string') {
|
||||
channels.push(`#${content.channel}`);
|
||||
}
|
||||
if (Array.isArray(content.mentions)) {
|
||||
for (let mention of content.mentions) {
|
||||
if (typeof mention?.link === 'string' &&
|
||||
mention.link.startsWith('#')) {
|
||||
channels.push(mention.link);
|
||||
}
|
||||
}
|
||||
}
|
||||
return channels.map(x => html`<tf-tag tag=${x}></tf-tag>`);
|
||||
}
|
||||
|
||||
async try_decrypt(content) {
|
||||
let result = await tfrpc.rpc.try_decrypt(this.whoami, content);
|
||||
if (result) {
|
||||
this.decrypted = JSON.parse(result);
|
||||
} else {
|
||||
this.decrypted = false;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let content = this.message?.content;
|
||||
if (this.decrypted?.type == 'post') {
|
||||
content = this.decrypted;
|
||||
}
|
||||
let self = this;
|
||||
let raw_button = this.raw ?
|
||||
html`<input type="button" value="Message" @click=${() => self.raw = false}></input>` :
|
||||
html`<input type="button" value="Raw" @click=${() => self.raw = true}></input>`;
|
||||
function small_frame(inner) {
|
||||
return html`
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block; overflow-wrap: anywhere">
|
||||
<tf-user id=${self.message.author} .users=${self.users}></tf-user>
|
||||
<span style="padding-right: 8px"><a tfarget="_top" href=${'#' + self.message.id}>%</a> ${new Date(self.message.timestamp).toLocaleString()}</span>
|
||||
${raw_button}
|
||||
${self.raw ? self.render_raw() : inner}
|
||||
${self.render_votes()}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
if (this.message?.type === 'contact_group') {
|
||||
return html`
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere">
|
||||
${this.message.messages.map(x =>
|
||||
html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>`
|
||||
)}
|
||||
</div>`;
|
||||
} else if (this.message.placeholder) {
|
||||
return html`
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere">
|
||||
<a target="_top" href=${'#' + this.message.id}>${this.message.id}</a> (placeholder)
|
||||
<div>${this.render_votes()}</div>
|
||||
${(this.message.child_messages || []).map(x => html`
|
||||
<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>
|
||||
`)}
|
||||
</div>`;
|
||||
} else if (typeof(content?.type === 'string')) {
|
||||
if (content.type == 'about') {
|
||||
let name;
|
||||
let image;
|
||||
let description;
|
||||
if (content.name !== undefined) {
|
||||
name = html`<div><b>Name:</b> ${content.name}</div>`;
|
||||
}
|
||||
if (content.image !== undefined) {
|
||||
image = html`
|
||||
<div><img src=${'/' + (typeof(content.image?.link) == 'string' ? content.image.link : content.image) + '/view'} style="width: 256px; height: auto"></img></div>
|
||||
`;
|
||||
}
|
||||
if (content.description !== undefined) {
|
||||
description = html`
|
||||
<div style="flex: 1 0 50%; overflow-wrap: anywhere">
|
||||
<div>${unsafeHTML(tfutils.markdown(content.description))}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
let update = content.about == this.message.author ?
|
||||
html`<div style="font-weight: bold">Updated profile.</div>` :
|
||||
html`<div style="font-weight: bold">Updated profile for <tf-user id=${content.about} .users=${this.users}></tf-user>.</div>`;
|
||||
return small_frame(html`
|
||||
${update}
|
||||
${name}
|
||||
${image}
|
||||
${description}
|
||||
`);
|
||||
} else if (content.type == 'contact') {
|
||||
return html`
|
||||
<div>
|
||||
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
|
||||
is
|
||||
${
|
||||
content.blocking === true ? 'blocking' :
|
||||
content.blocking === false ? 'no longer blocking' :
|
||||
content.following === true ? 'following' :
|
||||
content.following === false ? 'no longer following' :
|
||||
'?'
|
||||
}
|
||||
<tf-user id=${this.message.content.contact} .users=${this.users}></tf-user>
|
||||
</div>
|
||||
`;
|
||||
} else if (content.type == 'post') {
|
||||
let reply = (this.drafts[this.message?.id] !== undefined) ? html`
|
||||
<tf-compose
|
||||
whoami=${this.whoami}
|
||||
.users=${this.users}
|
||||
root=${this.message.content.root || this.message.id}
|
||||
branch=${this.message.id}
|
||||
.drafts=${this.drafts}
|
||||
@tf-discard=${this.discard_reply}></tf-compose>
|
||||
` : html`
|
||||
<input type="button" value="Reply" @click=${this.show_reply}></input>
|
||||
`;
|
||||
let self = this;
|
||||
let body = this.raw ?
|
||||
this.render_raw() :
|
||||
unsafeHTML(tfutils.markdown(content.text));
|
||||
let content_warning = html`
|
||||
<div class="content_warning" @click=${x => this.toggle_expanded(':cw')}>${content.contentWarning}</div>
|
||||
`;
|
||||
let content_html =
|
||||
html`
|
||||
${this.render_channels()}
|
||||
<div @click=${this.body_click}>${body}</div>
|
||||
${this.render_mentions()}
|
||||
`;
|
||||
let payload =
|
||||
content.contentWarning ?
|
||||
self.expanded[(this.message.id || '') + ':cw'] ?
|
||||
html`
|
||||
${content_warning}
|
||||
${content_html}
|
||||
` :
|
||||
content_warning :
|
||||
content_html;
|
||||
let is_encrypted = this.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined;
|
||||
let style_background = this.decrypted ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)';
|
||||
return html`
|
||||
<style>
|
||||
code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
div {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<div style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px">
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
|
||||
${is_encrypted}
|
||||
<span style="flex: 1"></span>
|
||||
<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span>
|
||||
<span>${raw_button}</span>
|
||||
</div>
|
||||
${payload}
|
||||
${this.render_votes()}
|
||||
<div>
|
||||
${reply}
|
||||
<input type="button" value="React" @click=${this.react}></input>
|
||||
</div>
|
||||
${this.render_children()}
|
||||
</div>
|
||||
`;
|
||||
} else if (content.type === 'blog') {
|
||||
let self = this;
|
||||
tfrpc.rpc.get_blob(content.blog).then(function(data) {
|
||||
self.blog_data = data;
|
||||
});
|
||||
let payload =
|
||||
this.expanded[(this.message.id || '') + ':blog'] ?
|
||||
html`<div>${this.blog_data ? unsafeHTML(tfutils.markdown(this.blog_data)) : 'Loading...'}</div>` :
|
||||
undefined;
|
||||
let body = this.raw ?
|
||||
this.render_raw() :
|
||||
html`
|
||||
<div
|
||||
style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer"
|
||||
@click=${x => self.toggle_expanded(':blog')}>
|
||||
<h2>${content.title}</h2>
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<img src=/${content.thumbnail}/view></img>
|
||||
<span>${content.summary}</span>
|
||||
</div>
|
||||
</div>
|
||||
${payload}
|
||||
`;
|
||||
return html`
|
||||
<style>
|
||||
code {
|
||||
white-space: pre-wrap;
|
||||
overflow-wrap: break-word;
|
||||
}
|
||||
div {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px">
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
|
||||
<span style="flex: 1"></span>
|
||||
<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span>
|
||||
<span>${raw_button}</span>
|
||||
</div>
|
||||
|
||||
<div>${body}</div>
|
||||
${this.render_mentions()}
|
||||
${this.render_votes()}
|
||||
</div>
|
||||
`;
|
||||
} else if (content.type === 'pub') {
|
||||
return small_frame(html`
|
||||
<style>
|
||||
span {
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
</style>
|
||||
<span>
|
||||
<div>
|
||||
🍻 <tf-user .users=${this.users} id=${content.address.key}></tf-user>
|
||||
</div>
|
||||
<pre>${content.address.host}:${content.address.port}</pre>
|
||||
</span>`);
|
||||
} else if (content.type === 'channel') {
|
||||
return small_frame(html`
|
||||
<div>
|
||||
${content.subscribed ? 'subscribed to' : 'unsubscribed from'} <a href=${'#q=' + encodeURIComponent('#' + content.channel)}>#${content.channel}</a>
|
||||
</div>
|
||||
`);
|
||||
} else if (typeof(this.message.content) == 'string') {
|
||||
if (this.decrypted) {
|
||||
return small_frame(html`<span>🔓</span><pre>${JSON.stringify(this.decrypted, null, 2)}</pre>`);
|
||||
} else if (this.decrypted === undefined) {
|
||||
this.try_decrypt(content);
|
||||
return small_frame(html`<span>🔐</span>`);
|
||||
} else {
|
||||
return small_frame(html`<span>🔒</span>`);
|
||||
}
|
||||
} else {
|
||||
return small_frame(html`<div><b>type</b>: ${content.type}</div>`);
|
||||
}
|
||||
} else {
|
||||
return small_frame(this.render_raw());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-message', TfMessageElement);
|
@ -9,7 +9,9 @@ class TfNewsElement extends LitElement {
|
||||
users: {type: Object},
|
||||
messages: {type: Array},
|
||||
following: {type: Array},
|
||||
}
|
||||
drafts: {type: Object},
|
||||
expanded: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
@ -21,6 +23,8 @@ class TfNewsElement extends LitElement {
|
||||
this.users = {};
|
||||
this.messages = [];
|
||||
this.following = [];
|
||||
this.drafts = {};
|
||||
this.expanded = {};
|
||||
}
|
||||
|
||||
process_messages(messages) {
|
||||
@ -77,6 +81,7 @@ class TfNewsElement extends LitElement {
|
||||
}
|
||||
|
||||
for (let message of messages) {
|
||||
message.votes = [];
|
||||
message.parent_message = undefined;
|
||||
message.child_messages = undefined;
|
||||
}
|
||||
@ -140,19 +145,38 @@ class TfNewsElement extends LitElement {
|
||||
return recursive_sort(roots, true);
|
||||
}
|
||||
|
||||
async load_and_render(messages) {
|
||||
group_following(messages) {
|
||||
let result = [];
|
||||
let group = [];
|
||||
for (let message of messages) {
|
||||
if (message?.content?.type === 'contact') {
|
||||
group.push(message);
|
||||
} else {
|
||||
if (group.length > 0) {
|
||||
result.push({
|
||||
type: 'contact_group',
|
||||
messages: group,
|
||||
});
|
||||
group = [];
|
||||
}
|
||||
result.push(message);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
load_and_render(messages) {
|
||||
let messages_by_id = this.process_messages(messages);
|
||||
let final_messages = this.finalize_messages(messages_by_id);
|
||||
let final_messages = this.group_following(this.finalize_messages(messages_by_id));
|
||||
return html`
|
||||
<div style="display: flex; flex-direction: column">
|
||||
${final_messages.map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} collapsed=true></tf-message>`)}
|
||||
${final_messages.map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded} collapsed=true></tf-message>`)}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
let messages = this.load_and_render(this.messages || []);
|
||||
return html`${until(messages, html`<div>Loading placeholders...</div>`)}`;
|
||||
return this.load_and_render(this.messages || []);
|
||||
}
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ class TfProfileElement extends LitElement {
|
||||
id: {type: String},
|
||||
users: {type: Object},
|
||||
size: {type: Number},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
@ -33,7 +33,7 @@ class TfProfileElement extends LitElement {
|
||||
contact: this.id,
|
||||
}, change)).catch(function(error) {
|
||||
alert(error?.message);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
follow() {
|
||||
@ -148,6 +148,10 @@ class TfProfileElement extends LitElement {
|
||||
<div><label for="description">Description:</label></div>
|
||||
<textarea id="description" @input=${event => this.editing = Object.assign({}, this.editing, {description: event.srcElement.value})}>${this.editing.description}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="public_web_hosting">Public Web Hosting:</label>
|
||||
<input type="checkbox" id="public_web_hosting" value=${this.editing.public_web_hosting} @input=${event => this.editing = Object.assign({}, this.editing, {publicWebHosting: event.srcElement.checked})}></input>
|
||||
</div>
|
||||
<input type="button" value="Attach Image" @click=${this.attach_image}></input>
|
||||
</div>` : null;
|
||||
let image = typeof(profile.image) == 'string' ? profile.image : profile.image?.link;
|
48
apps/ssb/tf-styles.js
Normal file
48
apps/ssb/tf-styles.js
Normal file
@ -0,0 +1,48 @@
|
||||
import {css} from './lit-all.min.js';
|
||||
|
||||
export let styles = css`
|
||||
a:link {
|
||||
color: #bbf;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #ddd;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #ddf;
|
||||
}
|
||||
|
||||
img {
|
||||
max-width: min(640px, 100%);
|
||||
max-height: min(480px, auto);
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: 0;
|
||||
padding: 8px;
|
||||
margin: 0px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tab:disabled {
|
||||
color: #088;
|
||||
background-color: #fff;
|
||||
}
|
||||
|
||||
.content_warning {
|
||||
border: 1px solid #fff;
|
||||
border-radius: 1em;
|
||||
padding: 8px;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
div.img_caption {
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div.img_caption::after {
|
||||
content: ' ±';
|
||||
}
|
||||
`;
|
@ -7,8 +7,9 @@ class TfTabConnectionsElement extends LitElement {
|
||||
broadcasts: {type: Array},
|
||||
identities: {type: Array},
|
||||
connections: {type: Array},
|
||||
stored_connections: {type: Array},
|
||||
users: {type: Object},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@ -17,10 +18,14 @@ class TfTabConnectionsElement extends LitElement {
|
||||
this.broadcasts = [];
|
||||
this.identities = [];
|
||||
this.connections = [];
|
||||
this.stored_connections = [];
|
||||
this.users = {};
|
||||
tfrpc.rpc.getAllIdentities().then(function(identities) {
|
||||
self.identities = identities || [];
|
||||
});
|
||||
tfrpc.rpc.getStoredConnections().then(function(connections) {
|
||||
self.stored_connections = connections || [];
|
||||
});
|
||||
}
|
||||
|
||||
render_connection_summary(connection) {
|
||||
@ -46,8 +51,7 @@ class TfTabConnectionsElement extends LitElement {
|
||||
}
|
||||
|
||||
async _tunnel(portal, target) {
|
||||
let request_number = await tfrpc.rpc.connectionSendJson(portal, {name: ['tunnel', 'connect'], args: [{portal: portal, target: target}], type: 'duplex'});
|
||||
return tfrpc.rpc.createTunnel(portal, request_number, target);
|
||||
return tfrpc.rpc.createTunnel(portal, target);
|
||||
}
|
||||
|
||||
render_room_peer(connection) {
|
||||
@ -67,7 +71,12 @@ class TfTabConnectionsElement extends LitElement {
|
||||
<tf-user id=${connection.pubkey} .users=${this.users}></tf-user>
|
||||
${this.render_connection_summary(connection)}
|
||||
</li>
|
||||
`
|
||||
`;
|
||||
}
|
||||
|
||||
async forget_stored_connection(connection) {
|
||||
await tfrpc.rpc.forgetStoredConnection(connection);
|
||||
this.stored_connections = (await tfrpc.rpc.getStoredConnections()) || [];
|
||||
}
|
||||
|
||||
render() {
|
||||
@ -92,6 +101,16 @@ class TfTabConnectionsElement extends LitElement {
|
||||
</li>
|
||||
`)}
|
||||
</ul>
|
||||
<h2>Stored Connections (WIP)</h2>
|
||||
<ul>
|
||||
${this.stored_connections.map(x => html`
|
||||
<li>
|
||||
<input type="button" @click=${() => self.forget_stored_connection(x)} value="Forget"></input>
|
||||
<input type="button" @click=${() => tfrpc.rpc.connect(x)} value="Connect"></input>
|
||||
${x.address}:${x.port} <tf-user id=${x.pubkey} .users=${self.users}></tf-user>
|
||||
</li>
|
||||
`)}
|
||||
</ul>
|
||||
<h2>Local Accounts</h2>
|
||||
<ul>
|
||||
${this.identities.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)}
|
65
apps/ssb/tf-tab-mentions.js
Normal file
65
apps/ssb/tf-tab-mentions.js
Normal file
@ -0,0 +1,65 @@
|
||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfTabMentionsElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
users: {type: Object},
|
||||
following: {type: Array},
|
||||
expanded: {type: Object},
|
||||
messages: {type: Array},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.whoami = null;
|
||||
this.users = {};
|
||||
this.following = [];
|
||||
this.expanded = {};
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
async load() {
|
||||
console.log('Loading...', this.whoami);
|
||||
let results = await tfrpc.rpc.query(`
|
||||
SELECT messages.*
|
||||
FROM messages_fts(?)
|
||||
JOIN messages ON messages.rowid = messages_fts.rowid
|
||||
JOIN json_each(?) AS following ON messages.author = following.value
|
||||
WHERE messages.author != ?
|
||||
ORDER BY timestamp DESC limit 20
|
||||
`,
|
||||
['"' + this.whoami.replace('"', '""') + '"', JSON.stringify(this.following), this.whoami]);
|
||||
console.log('Done.');
|
||||
this.messages = results;
|
||||
}
|
||||
|
||||
on_expand(event) {
|
||||
if (event.detail.expanded) {
|
||||
let expand = {};
|
||||
expand[event.detail.id] = true;
|
||||
this.expanded = Object.assign({}, this.expanded, expand);
|
||||
} else {
|
||||
delete this.expanded[event.detail.id];
|
||||
this.expanded = Object.assign({}, this.expanded);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
if (!this.loading) {
|
||||
this.loading = true;
|
||||
this.load();
|
||||
}
|
||||
return html`
|
||||
<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users} .expanded=${this.expanded} @tf-expand=${this.on_expand}></tf-news>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('tf-tab-mentions', TfTabMentionsElement);
|
154
apps/ssb/tf-tab-news-feed.js
Normal file
154
apps/ssb/tf-tab-news-feed.js
Normal file
@ -0,0 +1,154 @@
|
||||
import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfTabNewsFeedElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
users: {type: Object},
|
||||
hash: {type: String},
|
||||
following: {type: Array},
|
||||
messages: {type: Array},
|
||||
drafts: {type: Object},
|
||||
expanded: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.whoami = null;
|
||||
this.users = {};
|
||||
this.hash = '#';
|
||||
this.following = [];
|
||||
this.drafts = {};
|
||||
this.expanded = {};
|
||||
this.start_time = new Date().valueOf() - 24 * 60 * 60 * 1000;
|
||||
}
|
||||
|
||||
async fetch_messages() {
|
||||
if (this.hash.startsWith('#@')) {
|
||||
let r = await tfrpc.rpc.query(
|
||||
`
|
||||
WITH mine AS (SELECT messages.*
|
||||
FROM messages
|
||||
WHERE messages.author = ?
|
||||
ORDER BY sequence DESC
|
||||
LIMIT 20)
|
||||
SELECT messages.*
|
||||
FROM mine
|
||||
JOIN messages_refs ON mine.id = messages_refs.ref
|
||||
JOIN messages ON messages_refs.message = messages.id
|
||||
UNION
|
||||
SELECT * FROM mine
|
||||
`,
|
||||
[
|
||||
this.hash.substring(1),
|
||||
]);
|
||||
return r;
|
||||
} else if (this.hash.startsWith('#%')) {
|
||||
return await tfrpc.rpc.query(
|
||||
`
|
||||
SELECT messages.*
|
||||
FROM messages
|
||||
WHERE id = ?1
|
||||
UNION
|
||||
SELECT messages.*
|
||||
FROM messages JOIN messages_refs
|
||||
ON messages.id = messages_refs.message
|
||||
WHERE messages_refs.ref = ?1
|
||||
`,
|
||||
[
|
||||
this.hash.substring(1),
|
||||
]);
|
||||
} else {
|
||||
return await tfrpc.rpc.query(
|
||||
`
|
||||
WITH news AS (SELECT messages.*
|
||||
FROM messages
|
||||
JOIN json_each(?) AS following ON messages.author = following.value
|
||||
WHERE messages.timestamp > ?
|
||||
ORDER BY messages.timestamp DESC)
|
||||
SELECT messages.*
|
||||
FROM news
|
||||
JOIN messages_refs ON news.id = messages_refs.ref
|
||||
JOIN messages ON messages_refs.message = messages.id
|
||||
UNION
|
||||
SELECT messages.*
|
||||
FROM news
|
||||
JOIN messages_refs ON news.id = messages_refs.message
|
||||
JOIN messages ON messages_refs.ref = messages.id
|
||||
UNION
|
||||
SELECT news.* FROM news
|
||||
`,
|
||||
[
|
||||
JSON.stringify(this.following),
|
||||
this.start_time,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
async load_more() {
|
||||
let last_start_time = this.start_time;
|
||||
this.start_time = last_start_time - 24 * 60 * 60 * 1000;
|
||||
let more = await tfrpc.rpc.query(
|
||||
`
|
||||
WITH news AS (SELECT messages.*
|
||||
FROM messages
|
||||
JOIN json_each(?) AS following ON messages.author = following.value
|
||||
WHERE messages.timestamp > ?
|
||||
AND messages.timestamp <= ?
|
||||
ORDER BY messages.timestamp DESC)
|
||||
SELECT messages.*
|
||||
FROM news
|
||||
JOIN messages_refs ON news.id = messages_refs.ref
|
||||
JOIN messages ON messages_refs.message = messages.id
|
||||
UNION
|
||||
SELECT messages.*
|
||||
FROM news
|
||||
JOIN messages_refs ON news.id = messages_refs.message
|
||||
JOIN messages ON messages_refs.ref = messages.id
|
||||
UNION
|
||||
SELECT news.* FROM news
|
||||
`,
|
||||
[
|
||||
JSON.stringify(this.following),
|
||||
this.start_time,
|
||||
last_start_time,
|
||||
]);
|
||||
this.messages = [...more, ...this.messages];
|
||||
}
|
||||
|
||||
render() {
|
||||
if (!this.messages ||
|
||||
this._messages_hash !== this.hash ||
|
||||
this._messages_following !== this.following) {
|
||||
console.log(`loading messages for ${this.whoami}`);
|
||||
let self = this;
|
||||
this.messages = [];
|
||||
this._messages_hash = this.hash;
|
||||
this._messages_following = this.following;
|
||||
this.fetch_messages().then(function(messages) {
|
||||
self.messages = messages;
|
||||
console.log(`loading mesages done for ${self.whoami}`);
|
||||
}).catch(function(error) {
|
||||
alert(JSON.stringify(error, null, 2));
|
||||
});
|
||||
}
|
||||
let more;
|
||||
if (!this.hash.startsWith('#@') && !this.hash.startsWith('#%')) {
|
||||
more = html`
|
||||
<input type="button" value="Load More" @click=${this.load_more}></input>
|
||||
`;
|
||||
}
|
||||
return html`
|
||||
<tf-news id="news" whoami=${this.whoami} .users=${this.users} .messages=${this.messages} .following=${this.following} .drafts=${this.drafts} .expanded=${this.expanded}></tf-news>
|
||||
${more}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-tab-news-feed', TfTabNewsFeedElement);
|
119
apps/ssb/tf-tab-news.js
Normal file
119
apps/ssb/tf-tab-news.js
Normal file
@ -0,0 +1,119 @@
|
||||
import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfTabNewsElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
users: {type: Object},
|
||||
hash: {type: String},
|
||||
unread: {type: Array},
|
||||
following: {type: Array},
|
||||
drafts: {type: Object},
|
||||
expanded: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.whoami = null;
|
||||
this.users = {};
|
||||
this.hash = '#';
|
||||
this.unread = [];
|
||||
this.following = [];
|
||||
this.cache = {};
|
||||
this.drafts = {};
|
||||
this.expanded = {};
|
||||
tfrpc.rpc.localStorageGet('drafts').then(function(d) {
|
||||
self.drafts = JSON.parse(d || '{}');
|
||||
});
|
||||
}
|
||||
|
||||
connectedCallback() {
|
||||
super.connectedCallback();
|
||||
document.body.addEventListener('keypress', this.on_keypress.bind(this));
|
||||
}
|
||||
|
||||
disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
document.body.removeEventListener('keypress', this.on_keypress.bind(this));
|
||||
}
|
||||
|
||||
show_more() {
|
||||
let unread = this.unread;
|
||||
let news = this.shadowRoot?.getElementById('news');
|
||||
if (news) {
|
||||
console.log('injecting messages', news.messages);
|
||||
news.messages = Object.values(Object.fromEntries([...this.unread, ...news.messages].map(x => [x.id, x])));
|
||||
this.dispatchEvent(new CustomEvent('refresh'));
|
||||
}
|
||||
}
|
||||
|
||||
new_messages_text() {
|
||||
if (!this.unread?.length) {
|
||||
return 'No new messages.';
|
||||
}
|
||||
let counts = {};
|
||||
for (let message of this.unread) {
|
||||
let type = 'private';
|
||||
try {
|
||||
type = JSON.parse(message.content).type || type;
|
||||
} catch {
|
||||
}
|
||||
counts[type] = (counts[type] || 0) + 1;
|
||||
}
|
||||
return 'Show New: ' + Object.keys(counts).sort().map(x => (counts[x].toString() + ' ' + x + 's')).join(', ');
|
||||
}
|
||||
|
||||
draft(event) {
|
||||
let id = event.detail.id || '';
|
||||
let previous = this.drafts[id];
|
||||
if (event.detail.draft !== undefined) {
|
||||
this.drafts[id] = event.detail.draft;
|
||||
} else {
|
||||
delete this.drafts[id];
|
||||
}
|
||||
/* Only trigger a re-render if we're creating a new draft or discarding an old one. */
|
||||
if ((previous !== undefined) != (event.detail.draft !== undefined)) {
|
||||
this.drafts = Object.assign({}, this.drafts);
|
||||
}
|
||||
tfrpc.rpc.localStorageSet('drafts', JSON.stringify(this.drafts));
|
||||
}
|
||||
|
||||
on_expand(event) {
|
||||
if (event.detail.expanded) {
|
||||
let expand = {};
|
||||
expand[event.detail.id] = true;
|
||||
this.expanded = Object.assign({}, this.expanded, expand);
|
||||
} else {
|
||||
delete this.expanded[event.detail.id];
|
||||
this.expanded = Object.assign({}, this.expanded);
|
||||
}
|
||||
}
|
||||
|
||||
on_keypress(event) {
|
||||
if (event.target === document.body &&
|
||||
event.key == '.') {
|
||||
this.show_more();
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let profile = this.hash.startsWith('#@') ?
|
||||
html`<tf-profile id=${this.hash.substring(1)} whoami=${this.whoami} .users=${this.users}></tf-profile>` : undefined;
|
||||
return html`
|
||||
<div><input type="button" value=${this.new_messages_text()} @click=${this.show_more}></input></div>
|
||||
<a target="_top" href="#" ?hidden=${this.hash.length <= 1}>🏠Home</a>
|
||||
<div>Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!</div>
|
||||
<div><tf-compose whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} @tf-draft=${this.draft}></tf-compose></div>
|
||||
${profile}
|
||||
<tf-tab-news-feed id="news" whoami=${this.whoami} .users=${this.users} .following=${this.following} hash=${this.hash} .drafts=${this.drafts} .expanded=${this.expanded} @tf-draft=${this.draft} @tf-expand=${this.on_expand}></tf-tab-news-feed>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-tab-news', TfTabNewsElement);
|
@ -9,7 +9,8 @@ class TfTabSearchElement extends LitElement {
|
||||
users: {type: Object},
|
||||
following: {type: Array},
|
||||
query: {type: String},
|
||||
}
|
||||
expanded: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
@ -20,6 +21,7 @@ class TfTabSearchElement extends LitElement {
|
||||
this.whoami = null;
|
||||
this.users = {};
|
||||
this.following = [];
|
||||
this.expanded = {};
|
||||
}
|
||||
|
||||
async search(query) {
|
||||
@ -55,8 +57,20 @@ class TfTabSearchElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
on_expand(event) {
|
||||
if (event.detail.expanded) {
|
||||
let expand = {};
|
||||
expand[event.detail.id] = true;
|
||||
this.expanded = Object.assign({}, this.expanded, expand);
|
||||
} else {
|
||||
delete this.expanded[event.detail.id];
|
||||
this.expanded = Object.assign({}, this.expanded);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.query !== this.last_query) {
|
||||
this.last_query = this.query;
|
||||
this.search(this.query);
|
||||
}
|
||||
let self = this;
|
||||
@ -65,7 +79,7 @@ class TfTabSearchElement extends LitElement {
|
||||
<input type="text" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input>
|
||||
<input type="button" value="Search" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}></input>
|
||||
</div>
|
||||
<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users}></tf-news>
|
||||
<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users} .expanded=${this.expanded} @tf-expand=${this.on_expand}></tf-news>
|
||||
`;
|
||||
}
|
||||
}
|
24
apps/ssb/tf-tag.js
Normal file
24
apps/ssb/tf-tag.js
Normal file
@ -0,0 +1,24 @@
|
||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfTagElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
tag: {type: String},
|
||||
count: {type: Number},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
}
|
||||
|
||||
render() {
|
||||
let number = this.count ? html` (${this.count})` : undefined;
|
||||
return html`<a href="#q=${this.tag}" style="display: inline-block; margin: 3px; border: 1px solid black; background-color: #444; padding: 4px; border-radius: 3px">${this.tag}${number}</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-tag', TfTagElement);
|
44
apps/ssb/tf-user.js
Normal file
44
apps/ssb/tf-user.js
Normal file
@ -0,0 +1,44 @@
|
||||
import {LitElement, html} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfUserElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
id: {type: String},
|
||||
users: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.id = null;
|
||||
this.users = {};
|
||||
}
|
||||
|
||||
render() {
|
||||
let name = this.users?.[this.id]?.name;
|
||||
name = name !== undefined ?
|
||||
html`<a target="_top" href=${'#' + this.id}>${name}</a>` :
|
||||
html`<a target="_top" href=${'#' + this.id}>${this.id}</a>`;
|
||||
|
||||
if (this.users[this.id]) {
|
||||
let image = this.users[this.id].image;
|
||||
image = typeof(image) == 'string' ? image : image?.link;
|
||||
return html`
|
||||
<div style="display: inline-block; font-weight: bold">
|
||||
<img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" ?hidden=${image === undefined} src="${image ? '/' + image + '/view' : undefined}">
|
||||
${name}
|
||||
</div>`;
|
||||
} else {
|
||||
return html`
|
||||
<div style="display: inline-block; font-weight: bold">
|
||||
${name}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-user', TfUserElement);
|
93
apps/ssb/tf-utils.js
Normal file
93
apps/ssb/tf-utils.js
Normal file
@ -0,0 +1,93 @@
|
||||
import * as linkify from './commonmark-linkify.js';
|
||||
import * as hashtagify from './commonmark-hashtag.js';
|
||||
|
||||
function image(node, entering) {
|
||||
if (node.firstChild?.type === 'text' &&
|
||||
node.firstChild.literal.startsWith('video:')) {
|
||||
if (entering) {
|
||||
this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>');
|
||||
this.lit('<source src="' + this.esc(node.destination) + '"></source>');
|
||||
this.disableTags += 1;
|
||||
} else {
|
||||
this.disableTags -= 1;
|
||||
this.lit('</video>');
|
||||
}
|
||||
} else if (node.firstChild?.type === 'text' &&
|
||||
node.firstChild.literal.startsWith('audio:')) {
|
||||
if (entering) {
|
||||
this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>');
|
||||
this.lit('<source src="' + this.esc(node.destination) + '"></source>');
|
||||
this.disableTags += 1;
|
||||
} else {
|
||||
this.disableTags -= 1;
|
||||
this.lit('</audio>');
|
||||
}
|
||||
} else {
|
||||
if (entering) {
|
||||
if (this.disableTags === 0) {
|
||||
this.lit('<div class="img_caption">' + this.esc(node.firstChild?.literal || node.destination) + '</div>');
|
||||
if (this.options.safe && potentiallyUnsafe(node.destination)) {
|
||||
this.lit('<img src="" alt="');
|
||||
} else {
|
||||
this.lit('<img src="' + this.esc(node.destination) + '" alt="');
|
||||
}
|
||||
}
|
||||
this.disableTags += 1;
|
||||
} else {
|
||||
this.disableTags -= 1;
|
||||
if (this.disableTags === 0) {
|
||||
if (node.title) {
|
||||
this.lit('" title="' + this.esc(node.title));
|
||||
}
|
||||
this.lit('" />');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function markdown(md) {
|
||||
var reader = new commonmark.Parser({safe: true});
|
||||
var writer = new commonmark.HtmlRenderer();
|
||||
writer.image = image;
|
||||
var parsed = reader.parse(md || '');
|
||||
parsed = linkify.transform(parsed);
|
||||
parsed = hashtagify.transform(parsed);
|
||||
var walker = parsed.walker();
|
||||
var event, node;
|
||||
while ((event = walker.next())) {
|
||||
node = event.node;
|
||||
if (event.entering) {
|
||||
if (node.type == 'link') {
|
||||
if (node.destination.startsWith('@') &&
|
||||
node.destination.endsWith('.ed25519')) {
|
||||
node.destination = '#' + node.destination;
|
||||
} else if (node.destination.startsWith('%') &&
|
||||
node.destination.endsWith('.sha256')) {
|
||||
node.destination = '#' + node.destination;
|
||||
} else if (node.destination.startsWith('&') &&
|
||||
node.destination.endsWith('.sha256')) {
|
||||
node.destination = '/' + node.destination + '/view';
|
||||
}
|
||||
} else if (node.type == 'image') {
|
||||
if (node.destination.startsWith('&')) {
|
||||
node.destination = '/' + node.destination + '/view';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return writer.render(parsed);
|
||||
}
|
||||
|
||||
export function human_readable_size(bytes) {
|
||||
let v = bytes;
|
||||
let u = 'B';
|
||||
for (let unit of ['kB', 'MB', 'GB']) {
|
||||
if (v > 1024) {
|
||||
v /= 1024;
|
||||
u = unit;
|
||||
} else {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return `${Math.round(v * 10) / 10} ${u}`;
|
||||
}
|
4
apps/todo.json
Normal file
4
apps/todo.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "☑️"
|
||||
}
|
187
apps/todo/script.js
Normal file
187
apps/todo/script.js
Normal file
@ -0,0 +1,187 @@
|
||||
import {LitElement, html} from './lit-core.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
|
||||
class TodosElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
lists: {type: Array}
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.lists = [];
|
||||
let self = this;
|
||||
tfrpc.rpc.todo_get_all().then(function(lists) {
|
||||
self.lists = lists;
|
||||
}).catch(function(error) {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
|
||||
async new_list() {
|
||||
await tfrpc.rpc.todo_add('new list');
|
||||
await this.refresh();
|
||||
}
|
||||
|
||||
async refresh() {
|
||||
this.lists = await tfrpc.rpc.todo_get_all();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div>
|
||||
<div style="display: flex">
|
||||
${this.lists.map(x => html`
|
||||
<tf-todo-list name=${x.name} .items=${x.items} @change=${this.refresh}></tf-todo-list>
|
||||
`)}
|
||||
</div>
|
||||
<input type="button" @click=${this.new_list} value="+ List"></input>
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
||||
class TodoListElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
name: {type: String},
|
||||
items: {type: Array},
|
||||
editing: {type: Number},
|
||||
editing_name: {type: Boolean},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.items = [];
|
||||
}
|
||||
|
||||
save() {
|
||||
let self = this;
|
||||
console.log('saving', self.name, self.items);
|
||||
tfrpc.rpc.todo_set(self.name, self.items).then(function() {
|
||||
console.log('saved', self.name, self.items);
|
||||
}).catch(function(error) {
|
||||
console.log(error);
|
||||
});
|
||||
}
|
||||
|
||||
remove_item(item) {
|
||||
let index = this.items.indexOf(item);
|
||||
this.items = [].concat(this.items.slice(0, index), this.items.slice(index + 1));
|
||||
this.save();
|
||||
}
|
||||
|
||||
handle_check(event, item) {
|
||||
item.x = event.srcElement.checked;
|
||||
this.save();
|
||||
}
|
||||
|
||||
input_blur(item) {
|
||||
this.save();
|
||||
this.editing = undefined;
|
||||
}
|
||||
|
||||
input_change(event, item) {
|
||||
item.text = event.srcElement.value;
|
||||
}
|
||||
|
||||
input_keydown(event, item) {
|
||||
if (event.key === 'Enter' || event.key === 'Escape') {
|
||||
item.text = event.srcElement.value;
|
||||
this.editing = undefined;
|
||||
this.save();
|
||||
}
|
||||
}
|
||||
|
||||
updated() {
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
if (edit) {
|
||||
edit.select();
|
||||
}
|
||||
}
|
||||
|
||||
render_item(item) {
|
||||
let index = this.items.indexOf(item);
|
||||
let self = this;
|
||||
if (index === this.editing) {
|
||||
return html`
|
||||
<div><input type="checkbox" ?checked=${item.x} @change=${x => self.handle_check(x, item)}></input>
|
||||
<input
|
||||
id="edit"
|
||||
type="text"
|
||||
value=${item.text}
|
||||
@change=${event => self.input_change(event, item)}
|
||||
@keydown=${event => self.input_keydown(event, item)}
|
||||
@blur=${x => self.input_blur(item)}></input>
|
||||
<span @click=${x => self.remove_item(item)} style="cursor: pointer">❎</span></div>
|
||||
`;
|
||||
} else {
|
||||
return html`
|
||||
<div><input type="checkbox" ?checked=${item.x} @change=${x => self.handle_check(x, item)}></input>
|
||||
<span @click=${x => self.editing = index}>${item.text || '(empty)'}</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
add_item() {
|
||||
this.items = [].concat(this.items || [], [{text: 'new item'}]);
|
||||
this.editing = this.items.length - 1;
|
||||
this.save();
|
||||
}
|
||||
|
||||
async remove_list() {
|
||||
if (confirm(`Are you sure you want to remove "${this.name}"?`)) {
|
||||
await tfrpc.rpc.todo_remove(this.name);
|
||||
this.dispatchEvent(new Event('change'));
|
||||
}
|
||||
}
|
||||
|
||||
rename(new_name) {
|
||||
let self = this;
|
||||
return tfrpc.rpc.todo_rename(this.name, new_name).then(function() {
|
||||
self.dispatchEvent(new Event('change'));
|
||||
self.editing_name = false;
|
||||
}).catch(function(error) {
|
||||
console.log(error);
|
||||
alert(error.message);
|
||||
self.editing_name = false;
|
||||
});
|
||||
}
|
||||
|
||||
name_blur(new_name) {
|
||||
this.rename(new_name);
|
||||
}
|
||||
|
||||
name_keydown(event, item) {
|
||||
let self = this;
|
||||
if (event.key == 'Enter' || event.key === 'Escape') {
|
||||
let new_name = event.srcElement.value;
|
||||
this.rename(new_name);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
let name = this.editing_name ?
|
||||
html`<input
|
||||
type="text"
|
||||
id="edit"
|
||||
@keydown=${event => self.name_keydown(event)}
|
||||
@blur=${event => self.name_blur(event.srcElement.value)}
|
||||
value=${this.name}></input>` :
|
||||
html`<h2 @click=${x => this.editing_name = true}>${this.name}</h2>`;
|
||||
return html`
|
||||
<div style="border: 3px solid black; padding: 8px; margin: 8px; border-radius: 8px; background-color: #444">
|
||||
${name}
|
||||
${(this.items || []).filter(item => !item.x).map(x => self.render_item(x))}
|
||||
${(this.items || []).filter(item => item.x).map(x => self.render_item(x))}
|
||||
<button @click=${self.add_item}>+ Item</button>
|
||||
<button @click=${self.remove_list}>- List</button>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-todo-list', TodoListElement);
|
||||
customElements.define('tf-todos', TodosElement);
|
21
core/app.js
21
core/app.js
@ -22,14 +22,14 @@ App.prototype.readOutput = function(callback) {
|
||||
|
||||
App.prototype.makeFunction = function(api) {
|
||||
let self = this;
|
||||
let id = g_next_id++;
|
||||
while (!id || g_calls[id]) {
|
||||
id = g_next_id++;
|
||||
}
|
||||
let promise = new Promise(function(resolve, reject) {
|
||||
g_calls[id] = {resolve: resolve, reject: reject};
|
||||
});
|
||||
let result = function() {
|
||||
let id = g_next_id++;
|
||||
while (!id || g_calls[id]) {
|
||||
id = g_next_id++;
|
||||
}
|
||||
let promise = new Promise(function(resolve, reject) {
|
||||
g_calls[id] = {resolve: resolve, reject: reject};
|
||||
});
|
||||
let message = {
|
||||
message: 'tfrpc',
|
||||
method: api[0],
|
||||
@ -61,8 +61,7 @@ function socket(request, response, client) {
|
||||
let process;
|
||||
let options = {};
|
||||
let credentials = auth.query(request.headers);
|
||||
let refresh_token = credentials?.refresh?.token;
|
||||
let refresh_interval = credentials?.refresh?.interval;
|
||||
let refresh = auth.make_refresh(credentials);
|
||||
|
||||
response.onClose = async function() {
|
||||
if (process && process.task) {
|
||||
@ -198,9 +197,9 @@ function socket(request, response, client) {
|
||||
}
|
||||
}
|
||||
|
||||
if (refresh_token) {
|
||||
if (refresh) {
|
||||
return {
|
||||
'Set-Cookie': `session=${refresh_token}; path=/; Max-Age=${refresh_interval}; Secure; SameSite=Strict`,
|
||||
'Set-Cookie': `session=${refresh.token}; path=/; Max-Age=${refresh.interval}; Secure; SameSite=Strict`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
69
core/auth.js
69
core/auth.js
@ -1,9 +1,7 @@
|
||||
import * as core from './core.js';
|
||||
import * as http from './http.js';
|
||||
import * as form from './form.js';
|
||||
|
||||
var gTokens = {};
|
||||
var gDatabase = new Database("auth");
|
||||
let gDatabase = new Database("auth");
|
||||
|
||||
const kRefreshInterval = 1 * 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
@ -76,7 +74,7 @@ function verifyPassword(password, hash) {
|
||||
}
|
||||
|
||||
function hashPassword(password) {
|
||||
var salt = bCrypt.gensalt(12);
|
||||
let salt = bCrypt.gensalt(12);
|
||||
return bCrypt.hashpw(password, salt);
|
||||
}
|
||||
|
||||
@ -100,14 +98,14 @@ function makeAdministrator(name) {
|
||||
}
|
||||
|
||||
function getCookies(headers) {
|
||||
var cookies = {};
|
||||
let cookies = {};
|
||||
|
||||
if (headers.cookie) {
|
||||
var parts = headers.cookie.split(/,|;/);
|
||||
for (var i in parts) {
|
||||
var equals = parts[i].indexOf("=");
|
||||
var name = parts[i].substring(0, equals).trim();
|
||||
var value = parts[i].substring(equals + 1).trim();
|
||||
let parts = headers.cookie.split(/,|;/);
|
||||
for (let i in parts) {
|
||||
let equals = parts[i].indexOf("=");
|
||||
let name = parts[i].substring(0, equals).trim();
|
||||
let value = parts[i].substring(equals + 1).trim();
|
||||
cookies[name] = value;
|
||||
}
|
||||
}
|
||||
@ -116,18 +114,18 @@ function getCookies(headers) {
|
||||
}
|
||||
|
||||
function handler(request, response) {
|
||||
var session = getCookies(request.headers).session;
|
||||
let session = getCookies(request.headers).session;
|
||||
if (request.uri == "/login") {
|
||||
var sessionIsNew = false;
|
||||
var loginError;
|
||||
let sessionIsNew = false;
|
||||
let loginError;
|
||||
|
||||
var formData = form.decodeForm(request.query);
|
||||
let formData = form.decodeForm(request.query);
|
||||
|
||||
if (request.method == "POST" || formData.submit) {
|
||||
sessionIsNew = true;
|
||||
formData = form.decodeForm(utf8Decode(request.body), formData);
|
||||
if (formData.submit == "Login") {
|
||||
var account = gDatabase.get("user:" + formData.name);
|
||||
let account = gDatabase.get("user:" + formData.name);
|
||||
account = account ? JSON.parse(account) : account;
|
||||
if (formData.register == "1") {
|
||||
if (!account &&
|
||||
@ -172,15 +170,15 @@ function handler(request, response) {
|
||||
}
|
||||
}
|
||||
|
||||
var cookie = `session=${session}; path=/; Max-Age=${kRefreshInterval}; Secure; SameSite=Strict`;
|
||||
var entry = readSession(session);
|
||||
let cookie = `session=${session}; path=/; Max-Age=${kRefreshInterval}; ${request.client.tls ? 'Secure; ' : ''}SameSite=Strict`;
|
||||
let entry = readSession(session);
|
||||
if (entry && formData.return) {
|
||||
response.writeHead(303, {"Location": formData.return, "Set-Cookie": cookie});
|
||||
response.end();
|
||||
} else {
|
||||
File.readFile("core/auth.html").then(function(data) {
|
||||
var html = utf8Decode(data);
|
||||
var contents = "";
|
||||
let html = utf8Decode(data);
|
||||
let contents = "";
|
||||
|
||||
if (entry) {
|
||||
if (sessionIsNew) {
|
||||
@ -217,7 +215,7 @@ function handler(request, response) {
|
||||
contents += '</div>\n';
|
||||
contents += '</form>';
|
||||
}
|
||||
var text = html.replace("<!--SESSION-->", contents);
|
||||
let text = html.replace("<!--SESSION-->", contents);
|
||||
response.writeHead(200, {"Content-Type": "text/html; charset=utf-8", "Set-Cookie": cookie, "Content-Length": text.length});
|
||||
response.end(text);
|
||||
}).catch(function(error) {
|
||||
@ -226,7 +224,7 @@ function handler(request, response) {
|
||||
});
|
||||
}
|
||||
} else if (request.uri == "/login/logout") {
|
||||
response.writeHead(303, {"Set-Cookie": "session=; path=/; Secure; SameSite=Strict; expires=Thu, 01 Jan 1970 00:00:00 GMT", "Location": "/login" + (request.query ? "?" + request.query : "")});
|
||||
response.writeHead(303, {"Set-Cookie": `session=; path=/; ${request.client.tls ? 'Secure; ' : ''}SameSite=Strict; expires=Thu, 01 Jan 1970 00:00:00 GMT`, "Location": "/login" + (request.query ? "?" + request.query : "")});
|
||||
response.end();
|
||||
} else {
|
||||
response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8", "Connection": "close"});
|
||||
@ -235,8 +233,8 @@ function handler(request, response) {
|
||||
}
|
||||
|
||||
function getPermissions(session) {
|
||||
var permissions;
|
||||
var entry = readSession(session);
|
||||
let permissions;
|
||||
let entry = readSession(session);
|
||||
if (entry) {
|
||||
permissions = getPermissionsForUser(entry.name);
|
||||
permissions.authenticated = entry.name !== "guest";
|
||||
@ -245,9 +243,9 @@ function getPermissions(session) {
|
||||
}
|
||||
|
||||
function getPermissionsForUser(userName) {
|
||||
var permissions = {};
|
||||
let permissions = {};
|
||||
if (core.globalSettings && core.globalSettings.permissions && core.globalSettings.permissions[userName]) {
|
||||
for (var i in core.globalSettings.permissions[userName]) {
|
||||
for (let i in core.globalSettings.permissions[userName]) {
|
||||
permissions[core.globalSettings.permissions[userName][i]] = true;
|
||||
}
|
||||
}
|
||||
@ -255,19 +253,24 @@ function getPermissionsForUser(userName) {
|
||||
}
|
||||
|
||||
function query(headers) {
|
||||
var session = getCookies(headers).session;
|
||||
var entry;
|
||||
var autologin = tildefriends.args.autologin;
|
||||
let session = getCookies(headers).session;
|
||||
let entry;
|
||||
let autologin = tildefriends.args.autologin;
|
||||
if (entry = autologin ? {name: autologin} : readSession(session)) {
|
||||
return {
|
||||
session: entry,
|
||||
permissions: autologin ? getPermissionsForUser(autologin) : getPermissions(session),
|
||||
refresh: {
|
||||
token: makeJwt({name: entry.name}),
|
||||
interval: kRefreshInterval,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { handler, query };
|
||||
function make_refresh(credentials) {
|
||||
if (credentials?.session?.name) {
|
||||
return {
|
||||
token: makeJwt({name: credentials.session.name}),
|
||||
interval: kRefreshInterval,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export { handler, query, make_refresh };
|
||||
|
579
core/client.js
579
core/client.js
@ -1,17 +1,14 @@
|
||||
"use strict";
|
||||
import {LitElement, html, css, svg} from '/static/lit/lit-all.min.js';
|
||||
|
||||
let gSocket;
|
||||
let gCredentials;
|
||||
let gPermissions;
|
||||
|
||||
let gCurrentFile;
|
||||
let gFiles = {};
|
||||
let gApp = {files: {}};
|
||||
let gApp = {files: {}, emoji: '📦'};
|
||||
let gEditor;
|
||||
let gSplit;
|
||||
let gGraphs = {};
|
||||
let gTimeSeries = {};
|
||||
let gParentApp;
|
||||
let gOriginalInput;
|
||||
|
||||
let kErrorColor = "#dc322f";
|
||||
@ -29,6 +26,310 @@ const k_api = {
|
||||
setHash: {args: ['hash'], func: api_setHash},
|
||||
};
|
||||
|
||||
const k_global_style = css`
|
||||
a:link {
|
||||
color: #268bd2;
|
||||
}
|
||||
|
||||
a:visited {
|
||||
color: #6c71c4;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
color: #859900;
|
||||
}
|
||||
|
||||
a:active {
|
||||
color: #2aa198;
|
||||
}
|
||||
`;
|
||||
|
||||
class TfNavigationElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
credentials: {type: Object},
|
||||
permissions: {type: Object},
|
||||
show_permissions: {type: Boolean},
|
||||
status: {type: Object},
|
||||
spark_lines: {type: Object},
|
||||
version: {type: Object},
|
||||
show_version: {type: Boolean},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.permissions = {};
|
||||
this.show_permissions = false;
|
||||
this.status = {};
|
||||
this.spark_lines = {};
|
||||
}
|
||||
|
||||
toggle_edit(event) {
|
||||
event.preventDefault();
|
||||
if (editing()) {
|
||||
closeEditor();
|
||||
} else {
|
||||
edit();
|
||||
}
|
||||
}
|
||||
|
||||
reset_permission(key) {
|
||||
send({action: "resetPermission", permission: key});
|
||||
}
|
||||
|
||||
get_spark_line(key, options) {
|
||||
if (!this.spark_lines[key]) {
|
||||
let spark_line = document.createElement('tf-sparkline');
|
||||
spark_line.style.display = 'flex';
|
||||
spark_line.style.flexDirection = 'row';
|
||||
spark_line.style.flex = '0 100 10em';
|
||||
spark_line.title = key;
|
||||
if (options) {
|
||||
if (options.max) {
|
||||
spark_line.max = options.max;
|
||||
}
|
||||
}
|
||||
this.spark_lines[key] = spark_line;
|
||||
this.requestUpdate();
|
||||
}
|
||||
return this.spark_lines[key];
|
||||
}
|
||||
|
||||
render_login() {
|
||||
if (this?.credentials?.session?.name) {
|
||||
return html`<a href="/login/logout?return=${url() + hash()}">logout ${this.credentials.session.name}</a>`;
|
||||
} else {
|
||||
return html`<a href="/login?return=${url() + hash()}">login</a>`;
|
||||
}
|
||||
}
|
||||
|
||||
render_permissions() {
|
||||
if (this.show_permissions) {
|
||||
return html`
|
||||
<div style="position: absolute; top: 0; padding: 0; margin: 0; z-index: 100; display: flex; justify-content: center; width: 100%">
|
||||
<div style="background-color: #444; padding: 1em; margin: 0 auto; border-left: 4px solid #fff; border-right: 4px solid #fff; border-bottom: 4px solid #fff">
|
||||
<div>This app has the following permissions:</div>
|
||||
${Object.keys(this.permissions).map(key => html`
|
||||
<div>
|
||||
<span>${key}</span>: ${this.permissions[key] ? '✅ Allowed' : '❌ Denied'}
|
||||
<button @click=${() => this.reset_permission(key)}>Reset</button>
|
||||
</div>
|
||||
`)}
|
||||
<button @click=${() => this.show_permissions = false}>Close</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
return html`
|
||||
<style>
|
||||
${k_global_style}
|
||||
</style>
|
||||
<div style="margin: 4px; display: flex; flex-direction: row; flex-wrap: nowrap; gap: 3px">
|
||||
<span style="cursor: pointer" @click=${() => this.show_version = !this.show_version}>😎</span>
|
||||
<span ?hidden=${!this.show_version} style="flex: 0 0; white-space: nowrap" title=${this.version?.name + ' ' + Object.entries(this.version || {}).filter(x => ['name', 'number'].indexOf(x[0]) == -1).map(x => `\n* ${x[0]}: ${x[1]}`)}>${this.version?.number}</span>
|
||||
<a accesskey="h" data-tip="Open home app." href="/" style="color: #fff; white-space: nowrap">TF</a>
|
||||
<a accesskey="a" data-tip="Open apps list." href="/~core/apps/">apps</a>
|
||||
<a accesskey="e" data-tip="Toggle the app editor." href="#" @click=${this.toggle_edit}>edit</a>
|
||||
<a accesskey="p" data-tip="View and change permissions." href="#" @click=${() => self.show_permissions = !self.show_permissions}>🎛️</a>
|
||||
<span style="display: inline-block; vertical-align: top; white-space: pre; color: ${this.status.color ?? kErrorColor}">${this.status.message}</span>
|
||||
<span id="requests"></span>
|
||||
${this.render_permissions()}
|
||||
<span style="flex: 1 1; display: flex; flex-direction: row; white-space: nowrap; margin: 0; padding: 0">${Object.keys(this.spark_lines).sort().map(x => this.spark_lines[x]).map(x => [x.dataset.emoji, x])}</span>
|
||||
<span style="flex: 0 0; white-space: nowrap">${this.render_login()}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('tf-navigation', TfNavigationElement);
|
||||
|
||||
class TfFilesElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
current: {type: String},
|
||||
files: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.files = {};
|
||||
}
|
||||
|
||||
file_click(file) {
|
||||
this.dispatchEvent(new CustomEvent('file_click', {
|
||||
detail: {
|
||||
file: file,
|
||||
},
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
}));
|
||||
}
|
||||
|
||||
render_file(file) {
|
||||
let classes = ['file'];
|
||||
if (file == this.current) {
|
||||
classes.push('current');
|
||||
}
|
||||
if (!this.files[file].clean) {
|
||||
classes.push('dirty');
|
||||
}
|
||||
return html`<div class="${classes.join(' ')}" @click=${x => this.file_click(file)}>${file}</div>`;
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
return html`
|
||||
<style>
|
||||
div.file {
|
||||
padding: 0.5em;
|
||||
cursor: pointer;
|
||||
}
|
||||
div.file:hover {
|
||||
background-color: #1a9188;
|
||||
}
|
||||
div.file::before {
|
||||
content: '📄 ';
|
||||
}
|
||||
|
||||
div.file.current {
|
||||
font-weight: bold;
|
||||
background-color: #2aa198;
|
||||
}
|
||||
|
||||
div.file.dirty::after {
|
||||
content: '*';
|
||||
}
|
||||
</style>
|
||||
<div>
|
||||
${Object.keys(this.files).sort().map(x => self.render_file(x))}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('tf-files', TfFilesElement);
|
||||
|
||||
class TfFilesPaneElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
expanded: {type: Boolean},
|
||||
current: {type: String},
|
||||
files: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.expanded = window.localStorage.getItem('files') != '0';
|
||||
this.files = {};
|
||||
}
|
||||
|
||||
set_expanded(expanded) {
|
||||
this.expanded = expanded;
|
||||
window.localStorage.setItem('files', expanded ? '1' : '0');
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
let expander = this.expanded ?
|
||||
html`<span @click=${() => self.set_expanded(false)} class="expander">«</span>` :
|
||||
html`<span @click=${() => self.set_expanded(true)} class="expander">»</span>`;
|
||||
let content = html`
|
||||
<div id="files_content">
|
||||
<tf-files .files=${self.files} current=${self.current} @file_click=${event => openFile(event.detail.file)}></tf-files>
|
||||
<br>
|
||||
<div><button @click=${() => newFile()}>New File</button></div>
|
||||
<div><button @click=${() => removeFile()}>Remove File</button></div>
|
||||
</div>
|
||||
`;
|
||||
return html`
|
||||
<style>
|
||||
.expander {
|
||||
font-weight: bold;
|
||||
width: 100%;
|
||||
right: 0;
|
||||
flex: 0;
|
||||
padding: 0.25em;
|
||||
cursor: pointer;
|
||||
}
|
||||
</style>
|
||||
<div>
|
||||
<div style="display: flex; flex-direction: row">
|
||||
${this.expanded ? html`<span style="font-weight: bold; text-align: center; flex: 1">Files</span>` : undefined}
|
||||
${expander}
|
||||
</div>
|
||||
${this.expanded ? content : undefined}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('tf-files-pane', TfFilesPaneElement);
|
||||
|
||||
class TfSparkLineElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
lines: {type: Array},
|
||||
min: {type: Number},
|
||||
max: {type: Number},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.min = 0;
|
||||
this.max = 1.0;
|
||||
this.lines = [];
|
||||
this.k_values_max = 100;
|
||||
}
|
||||
|
||||
append(key, value) {
|
||||
let line = null;
|
||||
for (let it of this.lines) {
|
||||
if (it.name == key) {
|
||||
line = it;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!line) {
|
||||
const k_colors = ['#0f0', '#88f', '#ff0', '#f0f', '#0ff', '#f00', '#888'];
|
||||
line = {
|
||||
name: key,
|
||||
style: k_colors[this.lines.length % k_colors.length],
|
||||
values: Array(this.k_values_max).fill(0),
|
||||
};
|
||||
this.lines.push(line);
|
||||
}
|
||||
if (line.values.length >= this.k_values_max) {
|
||||
line.values.shift();
|
||||
}
|
||||
line.values.push(value);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render_line(line) {
|
||||
if (line?.values?.length >= 2) {
|
||||
let max = Math.max(this.max, ...line.values);
|
||||
let points = [].concat(...line.values.map((x, i) => [100.0 * i / (line.values.length - 1), 10.0 - 10.0 * (x - this.min) / (max - this.min)]));
|
||||
return svg`<polyline points=${points.join(' ')} stroke=${line.style} fill="none"/>`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
let max = Math.round(10.0 * Math.max(...this.lines.map(line => line.values[line.values.length - 1]))) / 10.0;
|
||||
return html`
|
||||
<svg style="width-auto: object-fit: cover; margin: 0; padding: 0; background: #000" viewBox="0 0 100 10" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg">
|
||||
${this.lines.map(x => this.render_line(x))}
|
||||
<text x="0" y="1em" style="font: 8px sans-serif; fill: #fff">${max}</text>
|
||||
</svg>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('tf-sparkline', TfSparkLineElement);
|
||||
|
||||
window.addEventListener("keydown", function(event) {
|
||||
if (event.keyCode == 83 && (event.altKey || event.ctrlKey)) {
|
||||
if (editing()) {
|
||||
@ -84,14 +385,6 @@ function editing() {
|
||||
return document.getElementById("editPane").style.display != 'none';
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
if (editing()) {
|
||||
closeEditor();
|
||||
} else {
|
||||
edit();
|
||||
}
|
||||
}
|
||||
|
||||
function edit() {
|
||||
if (editing()) {
|
||||
return;
|
||||
@ -103,6 +396,7 @@ function edit() {
|
||||
gSplit = undefined;
|
||||
}
|
||||
gSplit = Split(['#editPane', '#viewPane'], {minSize: 0});
|
||||
document.getElementById("editPane").style.display = 'flex';
|
||||
|
||||
ensureLoaded([
|
||||
{tagName: "script", attributes: {src: "/codemirror/codemirror.min.js"}},
|
||||
@ -110,6 +404,7 @@ function edit() {
|
||||
{tagName: "link", attributes: {rel: "stylesheet", href: "/codemirror/matchesonscrollbar.min.css"}},
|
||||
{tagName: "link", attributes: {rel: "stylesheet", href: "/codemirror/dialog.min.css"}},
|
||||
{tagName: "link", attributes: {rel: "stylesheet", href: "/codemirror/codemirror.min.css"}},
|
||||
{tagName: "link", attributes: {rel: "stylesheet", href: "/codemirror/lint.css"}},
|
||||
{tagName: "script", attributes: {src: "/codemirror/trailingspace.min.js"}},
|
||||
{tagName: "script", attributes: {src: "/codemirror/dialog.min.js"}},
|
||||
{tagName: "script", attributes: {src: "/codemirror/search.min.js"}},
|
||||
@ -121,6 +416,9 @@ function edit() {
|
||||
{tagName: "script", attributes: {src: "/codemirror/css.min.js"}},
|
||||
{tagName: "script", attributes: {src: "/codemirror/xml.min.js"}},
|
||||
{tagName: "script", attributes: {src: "/codemirror/htmlmixed.min.js"}},
|
||||
{tagName: "script", attributes: {src: "/codemirror/lint.js"}},
|
||||
{tagName: "script", attributes: {src: "/codemirror/jshint.js"}},
|
||||
{tagName: "script", attributes: {src: "/codemirror/javascript-lint.min.js"}},
|
||||
], function() {
|
||||
load().catch(function(error) {
|
||||
alert(error);
|
||||
@ -129,30 +427,18 @@ function edit() {
|
||||
});
|
||||
}
|
||||
|
||||
function hideFiles() {
|
||||
window.localStorage.setItem('files', '0');
|
||||
document.getElementById('filesPane').classList.add('collapsed');
|
||||
}
|
||||
|
||||
function showFiles() {
|
||||
window.localStorage.setItem('files', '1');
|
||||
document.getElementById('filesPane').classList.remove('collapsed');
|
||||
}
|
||||
|
||||
function trace() {
|
||||
window.open(`/speedscope/#profileURL=${encodeURIComponent('/trace')}&title=Tilde%20Friends`);
|
||||
window.open(`/speedscope/#profileURL=${encodeURIComponent('/trace')}`);
|
||||
}
|
||||
|
||||
function stats() {
|
||||
window.localStorage.setItem('stats', '1');
|
||||
document.getElementById("statsPane").style.display = 'flex';
|
||||
send({action: 'enableStats', enabled: true});
|
||||
}
|
||||
|
||||
function closeStats() {
|
||||
window.localStorage.setItem('stats', '0');
|
||||
document.getElementById("statsPane").style.display = 'none';
|
||||
send({action: 'enableStats', enabled: false});
|
||||
}
|
||||
|
||||
function toggleStats() {
|
||||
@ -178,7 +464,6 @@ function loadFile(name, id) {
|
||||
}).then(function(text) {
|
||||
gFiles[name].doc = new CodeMirror.Doc(text, guessMode(name));
|
||||
if (!Object.values(gFiles).some(x => !x.doc)) {
|
||||
document.getElementById("editPane").style.display = 'flex';
|
||||
openFile(Object.keys(gFiles).sort()[0]);
|
||||
}
|
||||
});
|
||||
@ -203,6 +488,13 @@ function load(path) {
|
||||
'indentUnit': 4,
|
||||
'indentWithTabs': true,
|
||||
'showTrailingSpace': true,
|
||||
'gutters': ['CodeMirror-lint-markers'],
|
||||
'mode': {'js': 'javascript'}[(path || url()).split('.').pop()],
|
||||
'lint': {
|
||||
'options': {
|
||||
'esversion': 2021,
|
||||
},
|
||||
},
|
||||
});
|
||||
gEditor.on('changes', function() {
|
||||
updateFiles();
|
||||
@ -222,6 +514,8 @@ function load(path) {
|
||||
document.getElementById("editPane").style.display = 'flex';
|
||||
}
|
||||
gApp = json;
|
||||
gApp.emoji = gApp.emoji || '📦';
|
||||
document.getElementById('icon').value = gApp.emoji;
|
||||
}
|
||||
if (!isApp) {
|
||||
document.getElementById("editPane").style.display = 'flex';
|
||||
@ -251,8 +545,6 @@ function explodePath() {
|
||||
|
||||
function save(save_to) {
|
||||
document.getElementById("save").disabled = true;
|
||||
document.getElementById("push_to_parent").disabled = true;
|
||||
document.getElementById("pull_from_parent").disabled = true;
|
||||
if (gCurrentFile) {
|
||||
gFiles[gCurrentFile].doc = gEditor.getDoc();
|
||||
}
|
||||
@ -298,6 +590,7 @@ function save(save_to) {
|
||||
let app = {
|
||||
type: "tildefriends-app",
|
||||
files: Object.fromEntries(Object.keys(gFiles).map(x => [x, gFiles[x].id || gApp.files[x]])),
|
||||
emoji: gApp.emoji || '📦',
|
||||
};
|
||||
Object.values(gFiles).forEach(function(file) { delete file.id; });
|
||||
gApp = JSON.parse(JSON.stringify(app));
|
||||
@ -323,8 +616,6 @@ function save(save_to) {
|
||||
alert(error);
|
||||
}).finally(function() {
|
||||
document.getElementById("save").disabled = false;
|
||||
document.getElementById("push_to_parent").disabled = false;
|
||||
document.getElementById("pull_from_parent").disabled = false;
|
||||
Object.values(gFiles).forEach(function(file) {
|
||||
file.generation = file.doc.changeGeneration();
|
||||
});
|
||||
@ -332,6 +623,14 @@ function save(save_to) {
|
||||
});
|
||||
}
|
||||
|
||||
function changeIcon() {
|
||||
let value = prompt('Enter a new app icon emoji:');
|
||||
if (value !== undefined) {
|
||||
gApp.emoji = value || '📦';
|
||||
document.getElementById('icon').value = gApp.emoji;
|
||||
}
|
||||
}
|
||||
|
||||
function deleteApp() {
|
||||
let name = document.getElementById("name");
|
||||
let path = name && name.value ? name.value : url();
|
||||
@ -348,16 +647,6 @@ function deleteApp() {
|
||||
}
|
||||
}
|
||||
|
||||
function pullFromParent() {
|
||||
load(gParentApp ? gParentApp.path : null).then(x => save()).catch(function(error) {
|
||||
alert(error)
|
||||
});
|
||||
}
|
||||
|
||||
function pushToParent() {
|
||||
save(gParentApp ? gParentApp.path : null);
|
||||
}
|
||||
|
||||
function url() {
|
||||
let hash = window.location.href.indexOf('#');
|
||||
let question = window.location.href.indexOf('?');
|
||||
@ -402,12 +691,13 @@ function api_localStorageSet(key, value) {
|
||||
window.localStorage.setItem('app:' + key, value);
|
||||
}
|
||||
|
||||
function api_localStorageGet(key, value) {
|
||||
function api_localStorageGet(key) {
|
||||
return window.localStorage.getItem('app:' + key);
|
||||
}
|
||||
|
||||
function api_requestPermission(permission, id) {
|
||||
let permissions = document.getElementById('permissions');
|
||||
let outer = document.createElement('div');
|
||||
outer.classList.add('permissions');
|
||||
|
||||
let container = document.createElement('div');
|
||||
container.classList.add('permissions_contents');
|
||||
@ -451,17 +741,14 @@ function api_requestPermission(permission, id) {
|
||||
button.innerText = option.text;
|
||||
button.onclick = function() {
|
||||
resolve(option.grant[check.checked ? 1 : 0]);
|
||||
while (permissions.firstChild) {
|
||||
permissions.removeChild(permissions.firstChild);
|
||||
}
|
||||
permissions.style.visibility = 'hidden';
|
||||
document.body.removeChild(outer);
|
||||
}
|
||||
div.appendChild(button);
|
||||
}
|
||||
container.appendChild(div);
|
||||
outer.appendChild(container);
|
||||
|
||||
permissions.appendChild(container);
|
||||
permissions.style.visibility = 'visible';
|
||||
document.body.appendChild(outer);
|
||||
});
|
||||
}
|
||||
|
||||
@ -473,89 +760,19 @@ function api_setHash(hash) {
|
||||
window.location.hash = hash;
|
||||
}
|
||||
|
||||
function hidePermissions() {
|
||||
let permissions = document.getElementById('permissions_settings');
|
||||
while (permissions.firstChild) {
|
||||
permissions.removeChild(permissions.firstChild);
|
||||
}
|
||||
permissions.style.visibility = 'hidden';
|
||||
}
|
||||
|
||||
function showPermissions() {
|
||||
let permissions = document.getElementById('permissions_settings');
|
||||
|
||||
let container = document.createElement('div');
|
||||
container.classList.add('permissions_contents');
|
||||
|
||||
let div = document.createElement('div');
|
||||
div.appendChild(document.createTextNode('This app has the following permission:'));
|
||||
for (let key of Object.keys(gPermissions || {})) {
|
||||
let row = document.createElement('div');
|
||||
|
||||
let span = document.createElement('span');
|
||||
span.appendChild(document.createTextNode(key));
|
||||
row.appendChild(span);
|
||||
|
||||
span = document.createElement('span');
|
||||
span.appendChild(document.createTextNode(': '));
|
||||
row.appendChild(span);
|
||||
|
||||
span = document.createElement('span');
|
||||
span.appendChild(document.createTextNode(gPermissions[key] ? '✅ Allowed' : '❌ Denied'));
|
||||
row.appendChild(span);
|
||||
|
||||
span = document.createElement('span');
|
||||
span.appendChild(document.createTextNode(' '));
|
||||
row.appendChild(span);
|
||||
|
||||
let button = document.createElement('button');
|
||||
button.innerText = 'Reset';
|
||||
button.onclick = function() {
|
||||
send({action: "resetPermission", permission: key});
|
||||
};
|
||||
row.appendChild(button);
|
||||
div.appendChild(row);
|
||||
}
|
||||
container.appendChild(div);
|
||||
|
||||
div = document.createElement('div');
|
||||
let button = document.createElement('button');
|
||||
button.innerText = 'Close';
|
||||
button.onclick = function() {
|
||||
hidePermissions();
|
||||
}
|
||||
div.appendChild(button);
|
||||
container.appendChild(div);
|
||||
|
||||
permissions.appendChild(container);
|
||||
permissions.style.visibility = 'visible';
|
||||
}
|
||||
|
||||
function _receive_websocket_message(message) {
|
||||
if (message && message.action == "session") {
|
||||
setStatusMessage("🟢 Executing...", kStatusColor);
|
||||
gCredentials = message.credentials;
|
||||
gParentApp = message.parentApp;
|
||||
updateLogin();
|
||||
let parent_enabled = message.parentApp;
|
||||
document.getElementById('push_to_parent').style.display = parent_enabled ? 'inline-block' : 'none';
|
||||
document.getElementById('pull_from_parent').style.display = parent_enabled ? 'inline-block' : 'none';
|
||||
document.getElementsByTagName('tf-navigation')[0].credentials = message.credentials;
|
||||
} else if (message && message.action == 'permissions') {
|
||||
gPermissions = message.permissions;
|
||||
let permissions = document.getElementById('permissions_settings');
|
||||
if (permissions.firstChild) {
|
||||
hidePermissions();
|
||||
showPermissions();
|
||||
}
|
||||
document.getElementsByTagName('tf-navigation')[0].permissions = message.permissions ?? {};
|
||||
} else if (message && message.action == "ready") {
|
||||
setStatusMessage(null);
|
||||
if (window.location.hash) {
|
||||
send({event: "hashChange", hash: window.location.hash});
|
||||
}
|
||||
if (window.localStorage.getItem('stats') == '1') {
|
||||
/* Stats were opened before we connected. */
|
||||
send({action: 'enableStats', enabled: true});
|
||||
}
|
||||
document.getElementsByTagName('tf-navigation')[0].version = message.version;
|
||||
send({action: 'enableStats', enabled: true});
|
||||
} else if (message && message.action == "ping") {
|
||||
send({action: "pong"});
|
||||
} else if (message && message.action == "stats") {
|
||||
@ -565,6 +782,9 @@ function _receive_websocket_message(message) {
|
||||
rpc_in: {group: 'rpc', name: 'in'},
|
||||
rpc_out: {group: 'rpc', name: 'out'},
|
||||
|
||||
cpu_percent: {group: 'cpu', name: 'main'},
|
||||
thread_percent: {group: 'cpu', name: 'work'},
|
||||
|
||||
arena_percent: {group: 'memory', name: 'm'},
|
||||
js_malloc_percent: {group: 'memory', name: 'js'},
|
||||
memory_percent: {group: 'memory', name: 'tot'},
|
||||
@ -573,6 +793,9 @@ function _receive_websocket_message(message) {
|
||||
tls_malloc_percent: {group: 'memory', name: 'tls'},
|
||||
uv_malloc_percent: {group: 'memory', name: 'uv'},
|
||||
|
||||
messages_stored: {group: 'store', name: 'messages'},
|
||||
blobs_stored: {group: 'store', name: 'blobs'},
|
||||
|
||||
socket_count: {group: 'socket', name: 'total'},
|
||||
socket_open_count: {group: 'socket', name: 'open'},
|
||||
|
||||
@ -631,22 +854,34 @@ function _receive_websocket_message(message) {
|
||||
}
|
||||
}
|
||||
timeseries.append(now, message.stats[key]);
|
||||
|
||||
if (graph_key == 'cpu' || graph_key == 'rpc' || graph_key == 'store') {
|
||||
let line = document.getElementsByTagName('tf-navigation')[0].get_spark_line(graph_key, { max: 100 });
|
||||
line.dataset.emoji = {
|
||||
'cpu': '💻',
|
||||
'rpc': '🔁',
|
||||
'store': '💾',
|
||||
}[graph_key];
|
||||
line.append(key, message.stats[key]);
|
||||
}
|
||||
}
|
||||
} else if (message &&
|
||||
message.message === 'tfrpc' &&
|
||||
message.method) {
|
||||
let api = k_api[message.method];
|
||||
let id = message.id;
|
||||
let params = message.params;
|
||||
if (api) {
|
||||
Promise.resolve(api.func(...message.params)).then(function(result) {
|
||||
Promise.resolve(api.func(...params)).then(function(result) {
|
||||
send({
|
||||
message: 'tfrpc',
|
||||
id: message.id,
|
||||
id: id,
|
||||
result: result,
|
||||
});
|
||||
}).catch(function(error) {
|
||||
send({
|
||||
message: 'tfrpc',
|
||||
id: message.id,
|
||||
id: id,
|
||||
error: error,
|
||||
});
|
||||
});
|
||||
@ -654,27 +889,8 @@ function _receive_websocket_message(message) {
|
||||
}
|
||||
}
|
||||
|
||||
function keyEvent(event) {
|
||||
send({
|
||||
event: "key",
|
||||
type: event.type,
|
||||
which: event.which,
|
||||
keyCode: event.keyCode,
|
||||
charCode: event.charCode,
|
||||
character: String.fromCharCode(event.keyCode || event.which),
|
||||
altKey: event.altKey,
|
||||
});
|
||||
}
|
||||
|
||||
function setStatusMessage(message, color) {
|
||||
let node = document.getElementById("status");
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild);
|
||||
}
|
||||
if (message) {
|
||||
node.appendChild(document.createTextNode(message));
|
||||
node.setAttribute("style", "display: inline-block; vertical-align: top; white-space: pre; color: " + (color || kErrorColor));
|
||||
}
|
||||
document.getElementsByTagName('tf-navigation')[0].status = {message: message, color: color};
|
||||
}
|
||||
|
||||
function send(value) {
|
||||
@ -687,23 +903,6 @@ function send(value) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateLogin() {
|
||||
let login = document.getElementById("login");
|
||||
while (login.firstChild) {
|
||||
login.removeChild(login.firstChild);
|
||||
}
|
||||
|
||||
let a = document.createElement("a");
|
||||
if (gCredentials && gCredentials.session) {
|
||||
a.appendChild(document.createTextNode("logout " + gCredentials.session.name));
|
||||
a.setAttribute("href", "/login/logout?return=" + encodeURIComponent(url() + hash()));
|
||||
} else {
|
||||
a.appendChild(document.createTextNode("login"));
|
||||
a.setAttribute("href", "/login?return=" + encodeURIComponent(url() + hash()));
|
||||
}
|
||||
login.appendChild(a);
|
||||
}
|
||||
|
||||
function dragHover(event) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
@ -844,10 +1043,12 @@ function message(event) {
|
||||
function reconnect(path) {
|
||||
let oldSocket = gSocket;
|
||||
gSocket = null
|
||||
oldSocket.onopen = null;
|
||||
oldSocket.onclose = null;
|
||||
oldSocket.onmessage = null;
|
||||
oldSocket.close();
|
||||
if (oldSocket) {
|
||||
oldSocket.onopen = null;
|
||||
oldSocket.onclose = null;
|
||||
oldSocket.onmessage = null;
|
||||
oldSocket.close();
|
||||
}
|
||||
connectSocket(path);
|
||||
}
|
||||
|
||||
@ -911,27 +1112,13 @@ function openFile(name) {
|
||||
gEditor.focus();
|
||||
}
|
||||
|
||||
function onFileClicked(event) {
|
||||
openFile(event.target.textContent);
|
||||
}
|
||||
|
||||
function updateFiles() {
|
||||
let node = document.getElementById("files");
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild);
|
||||
}
|
||||
|
||||
for (let file of Object.keys(gFiles).sort()) {
|
||||
let li = document.createElement("li");
|
||||
li.onclick = onFileClicked;
|
||||
li.appendChild(document.createTextNode(file));
|
||||
if (file == gCurrentFile) {
|
||||
li.classList.add("current");
|
||||
}
|
||||
if (!gFiles[file].doc.isClean(gFiles[file].generation)) {
|
||||
li.classList.add("dirty");
|
||||
}
|
||||
node.appendChild(li);
|
||||
let files = document.getElementsByTagName("tf-files-pane")[0];
|
||||
if (files) {
|
||||
files.files = Object.fromEntries(Object.keys(gFiles).map(file => [file, {
|
||||
clean: gFiles[file].doc.isClean(gFiles[file].generation),
|
||||
}]));
|
||||
files.current = gCurrentFile;
|
||||
}
|
||||
|
||||
gEditor.focus();
|
||||
@ -966,6 +1153,19 @@ window.addEventListener("load", function() {
|
||||
window.addEventListener("message", message, false);
|
||||
window.addEventListener("online", connectSocket);
|
||||
document.getElementById("name").value = window.location.pathname;
|
||||
document.getElementById('closeStats').addEventListener('click', () => closeStats());
|
||||
document.getElementById('closeEditor').addEventListener('click', () => closeEditor());
|
||||
document.getElementById('save').addEventListener('click', () => save());
|
||||
document.getElementById('icon').addEventListener('click', () => changeIcon());
|
||||
document.getElementById('delete').addEventListener('click', () => deleteApp());
|
||||
document.getElementById('trace_button').addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
trace();
|
||||
});
|
||||
document.getElementById('stats_button').addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
toggleStats();
|
||||
});
|
||||
for (let tag of document.getElementsByTagName('a')) {
|
||||
if (tag.accessKey) {
|
||||
tag.classList.add('tooltip_parent');
|
||||
@ -998,11 +1198,6 @@ window.addEventListener("load", function() {
|
||||
} else {
|
||||
closeEditor();
|
||||
}
|
||||
if (window.localStorage.getItem('files') == '1') {
|
||||
showFiles();
|
||||
} else {
|
||||
hideFiles();
|
||||
}
|
||||
if (window.localStorage.getItem('stats') == '1') {
|
||||
stats();
|
||||
} else {
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user