forked from cory/tildefriends
Compare commits
384 Commits
Author | SHA1 | Date | |
---|---|---|---|
d84ab2734e | |||
4f0cc793c7 | |||
b7a4ac22b2 | |||
5db9acae1d | |||
c299c1432c | |||
d8551ab732 | |||
25bc1279c2 | |||
f6d9d23456 | |||
b20d95d616 | |||
071c2f1c20 | |||
566d00f0df | |||
0550aa4e98 | |||
baf69355a5 | |||
17c0266998 | |||
14a2207064 | |||
7c721fc6eb | |||
ab03692a4c | |||
626fa4f27b | |||
15676e0f4f | |||
8709ce8ad5 | |||
bb924d79d6 | |||
8e075e33d9 | |||
b0b002104a | |||
43d1f34fa3 | |||
6db1a097aa | |||
6dae2f0749 | |||
dc87c26b99 | |||
234d597083 | |||
b74c347c7c | |||
996996e609 | |||
cd9050f61f | |||
b70b309977 | |||
ee510f3f3f | |||
8a70b8ea3e | |||
27ee73bb89 | |||
3aeb47e447 | |||
b9ceb30ecf | |||
5b6ee20b2d | |||
d062ec0dfe | |||
d5a0daa0d3 | |||
8c4ec71e26 | |||
1e5aa0ba93 | |||
0326a1f8cc | |||
8311404a09 | |||
c81111c2cf | |||
e499be12ae | |||
e785f7f10a | |||
c3da10bef6 | |||
c8c8cb305e | |||
efdd046ef8 | |||
496eefd2ee | |||
9da79b3a21 | |||
1b2b0970fb | |||
0bb45a7fa8 | |||
15a25a41aa | |||
76b6ff78cd | |||
5285b3f222 | |||
0c993c251b | |||
b3a1f17452 | |||
11929e8c68 | |||
fc9a081250 | |||
2583221117 | |||
a69e551968 | |||
8bd0027e71 | |||
84eaa3e2fd | |||
a57916b3db | |||
87e769786a | |||
05a7e941cf | |||
ed307e6b3b | |||
e8aa957209 | |||
a39f820ff1 | |||
1c621a602f | |||
7169f4a6cb | |||
e202c1a40e | |||
0f4b4da0aa | |||
1f96413bd3 | |||
9695621c91 | |||
2d67f5ead6 | |||
29d2a45abc | |||
13c8b05f9a | |||
2c1a5359c6 | |||
d8530f228e | |||
575f6c2f0a | |||
62cdc592c0 | |||
11373faf23 | |||
0473eec0a2 | |||
7fc23dc085 | |||
c741cc06b2 | |||
39dbfdec82 | |||
0e5d6056e4 | |||
d711993b3b | |||
615bf7fe43 | |||
424b9b5a2f | |||
d1e494b730 | |||
623e4b8fff | |||
b5dd1f2f86 | |||
ec83f9c747 | |||
31af27529e | |||
6302565942 | |||
cda724b2da | |||
c2e2ba2a40 | |||
b5d3f5faa7 | |||
71f3910055 | |||
70ed8c3b32 | |||
af13bfc920 | |||
e24fd92f85 | |||
7e27cefe6a | |||
450cf6424e | |||
54898d3dbb | |||
dd851a2b25 | |||
4c6b44eb30 | |||
74a3efe78d | |||
51301fc49e | |||
02dd8c3dd0 | |||
26a778c3b2 | |||
9fecbd97e8 | |||
e1383e3903 | |||
47532b8512 | |||
3c4959433a | |||
e921b4a86a | |||
b23b0ca239 | |||
191b45f054 | |||
15d0383349 | |||
d2485583fd | |||
2b94704916 | |||
85ac6c215a | |||
e83e665db9 | |||
645aafef16 | |||
152c893a6f | |||
7c130dda56 | |||
2d82dad806 | |||
e8ac5b759d | |||
4833d18968 | |||
6eafded1f6 | |||
7b440b720e | |||
e20ba7384f | |||
45231c6ede | |||
35475defb5 | |||
8741841f27 | |||
5282d19b55 | |||
d9782aa0fb | |||
9751facfb4 | |||
e0110203e7 | |||
088b44cc2c | |||
8f63bcbfbf | |||
c8029388c9 | |||
d9c4d847a1 | |||
df9d9425ec | |||
90bb3c684e | |||
9c81b6de8a | |||
6383498041 | |||
daeb88785d | |||
dcea08f73b | |||
b252b921f8 | |||
172826bf13 | |||
060f1980f5 | |||
e223d35252 | |||
99dba1a4c6 | |||
b52026c81f | |||
47b8c86426 | |||
2e55c68648 | |||
b7362dd84d | |||
01637b31e1 | |||
0e9a39608a | |||
79404e4d41 | |||
35c21fbdaf | |||
8c7bd7dc11 | |||
09ad4f0320 | |||
d96b836bef | |||
59b2ffaf95 | |||
f1b55ddd64 | |||
85acac3a30 | |||
befff5c1e5 | |||
d72ba81a67 | |||
fef88e2032 | |||
20557e8ce4 | |||
99c905e908 | |||
d7b58ee2c5 | |||
faca2d387b | |||
358d02d97f | |||
b66dac7465 | |||
f7d201859a | |||
61d2ef5469 | |||
ac994b9c62 | |||
264dcbc331 | |||
e5425c0ffb | |||
e10803de68 | |||
07b1a0e403 | |||
6ed2c702d8 | |||
5c1c33d33e | |||
70d37c88b5 | |||
1ba37d95b5 | |||
0d82198849 | |||
39927e75f2 | |||
e6fd33b969 | |||
e8fe32d5af | |||
bfc8bb864d | |||
9179746763 | |||
d0177d24cb | |||
0573008c9c | |||
9506f518c2 | |||
0f0ae9153b | |||
09c7c8ac64 | |||
5e2dfff148 | |||
958b47548d | |||
16155ef746 | |||
5755b61ea6 | |||
353847a77f | |||
bdf64edeb8 | |||
b5768dd927 | |||
3e5abf3a4d | |||
d3029639de | |||
d21d7e4add | |||
afde69b5d9 | |||
3319df3df0 | |||
1102feaac3 | |||
deede728be | |||
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 |
545
Makefile
545
Makefile
@ -3,50 +3,191 @@
|
||||
MAKEFLAGS += --warn-undefined-variables
|
||||
MAKEFLAGS += --no-builtin-rules
|
||||
|
||||
VERSION_CODE := 12
|
||||
VERSION_NUMBER := 0.0.12
|
||||
VERSION_NAME := Where everybody knows your name.
|
||||
|
||||
PROJECT = tildefriends
|
||||
BUILD_DIR ?= out
|
||||
BUILD_TYPES := debug release windebug winrelease androiddebug androidrelease
|
||||
UNAME_S := $(shell uname -s)
|
||||
UNAME_M := $(shell uname -m)
|
||||
|
||||
ANDROID_SDK ?= ~/Android/Sdk
|
||||
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
BUILD_TYPES := macosdebug macosrelease iosdebug iosrelease iossimdebug iossimrelease
|
||||
else ifeq ($(UNAME_S),Linux)
|
||||
BUILD_TYPES := debug release
|
||||
HAVE_ANDROID = $(if $(shell which $(ANDROID_SDK)/platform-tools/adb),1,0)
|
||||
HAVE_LINUX_IOS = $(if $(shell which deps/ios_toolchain/target/bin deps/ios_toolchain/target/bin/arm-apple-darwin11-clang),1,0)
|
||||
HAVE_WIN = $(if $(shell which x86_64-w64-mingw32-gcc-win32),1,0)
|
||||
else
|
||||
$(error Unexpected host platform $(UNAME_S).)
|
||||
endif
|
||||
|
||||
CFLAGS += \
|
||||
-Wall \
|
||||
-Wextra \
|
||||
-Wno-unused-parameter \
|
||||
-Wno-cast-function-type \
|
||||
-MMD \
|
||||
-ffunction-sections \
|
||||
-fdata-sections \
|
||||
-fno-omit-frame-pointer \
|
||||
-fno-exceptions \
|
||||
-g
|
||||
LDFLAGS += -Wl,--gc-sections
|
||||
|
||||
NDK_PATH := /usr/lib/android-sdk/ndk-bundle
|
||||
NDK_API_VERSION := 30
|
||||
NDK_TARGET_TRIPLE := aarch64-linux-android
|
||||
ANDROID_BUILD_TOOLS := $(ANDROID_SDK)/build-tools/34.0.0
|
||||
ANDROID_PLATFORM := $(ANDROID_SDK)/platforms/android-33
|
||||
ANDROID_NDK ?= $(ANDROID_SDK)/ndk/26.0.10792818
|
||||
ANDROID_MIN_SDK_VERSION := 24
|
||||
ANDROID_TARGET_SDK_VERSION := 34
|
||||
|
||||
debug windebug androiddebug: CFLAGS += -Og
|
||||
debug release androidrelease: LDFLAGS += -rdynamic
|
||||
release winrelease: CFLAGS += -DNDEBUG -O3
|
||||
windebug winrelease: CC = x86_64-w64-mingw32-gcc-win32
|
||||
windebug winrelease: AS = $(CC)
|
||||
windebug winrelease: CFLAGS += \
|
||||
ANDROID_ARMV7A_TARGETS := \
|
||||
out/androiddebug-armv7a/tildefriends \
|
||||
out/androidrelease-armv7a/tildefriends
|
||||
ANDROID_ARM64_TARGETS := \
|
||||
out/androiddebug/tildefriends \
|
||||
out/androidrelease/tildefriends
|
||||
ANDROID_X86_TARGETS := \
|
||||
out/androiddebug-x86/tildefriends \
|
||||
out/androidrelease-x86/tildefriends
|
||||
ANDROID_X86_64_TARGETS := \
|
||||
out/androiddebug-x86_64/tildefriends \
|
||||
out/androidrelease-x86_64/tildefriends
|
||||
ANDROID_TARGETS := \
|
||||
$(ANDROID_X86_TARGETS) \
|
||||
$(ANDROID_X86_64_TARGETS) \
|
||||
$(ANDROID_ARMV7A_TARGETS) \
|
||||
$(ANDROID_ARM64_TARGETS)
|
||||
ifeq ($(HAVE_ANDROID),1)
|
||||
BUILD_TYPES += \
|
||||
androiddebug \
|
||||
androidrelease \
|
||||
androiddebug-armv7a \
|
||||
androidrelease-armv7a \
|
||||
androiddebug-x86 \
|
||||
androidrelease-x86 \
|
||||
androiddebug-x86_64 \
|
||||
androidrelease-x86_64
|
||||
all: out/TildeFriends-arm-debug.apk out/TildeFriends-arm-release.apk out/TildeFriends-x86-debug.apk out/TildeFriends-x86-release.apk
|
||||
endif
|
||||
|
||||
WINDOWS_TARGETS := \
|
||||
out/windebug/tildefriends.exe \
|
||||
out/winrelease/tildefriends.exe
|
||||
ifeq ($(HAVE_WIN),1)
|
||||
BUILD_TYPES += windebug winrelease
|
||||
endif
|
||||
|
||||
LINUX_TARGETS := \
|
||||
out/debug/tildefriends \
|
||||
out/release/tildefriends
|
||||
MACOS_TARGETS := \
|
||||
out/macosdebug/tildefriends \
|
||||
out/macosrelease/tildefriends
|
||||
IOS_TARGETS := \
|
||||
out/iosdebug/tildefriends \
|
||||
out/iosrelease/tildefriends
|
||||
IOSSIM_TARGETS := \
|
||||
out/iossimdebug/tildefriends \
|
||||
out/iossimrelease/tildefriends
|
||||
IOS_APPS = \
|
||||
out/tildefriends-iosdebug.app/tildefriends \
|
||||
out/tildefriends-iosrelease.app/tildefriends
|
||||
ifeq ($(HAVE_LINUX_IOS),1)
|
||||
BUILD_TYPES += iosdebug iosrelease
|
||||
all: $(IOS_APPS)
|
||||
endif
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
all: $(IOS_APPS) \
|
||||
out/tildefriends-iossimdebug.app/tildefriends \
|
||||
out/tildefriends-iossimrelease.app/tildefriends
|
||||
endif
|
||||
|
||||
DEBUG_TARGETS := \
|
||||
out/debug/tildefriends \
|
||||
out/windebug/tildefriends.exe \
|
||||
out/iosdebug/tildefriends \
|
||||
out/iossimdebug/tildefriends \
|
||||
out/macosdebug/tildefriends \
|
||||
out/androiddebug/tildefriends \
|
||||
out/androiddebug-armv7a/tildefriends \
|
||||
out/androiddebug-x86_64/tildefriends \
|
||||
out/androiddebug-x86/tildefriends
|
||||
RELEASE_TARGETS := \
|
||||
out/release/tildefriends \
|
||||
out/winrelease/tildefriends.exe \
|
||||
out/iosrelease/tildefriends \
|
||||
out/iossimrelease/tildefriends \
|
||||
out/macosrelease/tildefriends \
|
||||
out/androidrelease/tildefriends \
|
||||
out/androidrelease-armv7a/tildefriends \
|
||||
out/androidrelease-x86_64/tildefriends \
|
||||
out/androidrelease-x86/tildefriends
|
||||
ALL_TARGETS = $(DEBUG_TARGETS) $(RELEASE_TARGETS)
|
||||
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),$(ALL_TARGETS))
|
||||
NONMACOS_TARGETS := $(filter-out $(MACOS_TARGETS) $(IOS_TARGETS) $(IOSSIM_TARGETS),$(ALL_TARGETS))
|
||||
|
||||
$(NONANDROID_TARGETS): CFLAGS += -fno-omit-frame-pointer
|
||||
$(filter-out $(ANDROID_TARGETS) $(WINDOWS_TARGETS),$(ALL_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 \
|
||||
-funwind-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
|
||||
$(WINDOWS_TARGETS): CC = x86_64-w64-mingw32-gcc-win32
|
||||
$(WINDOWS_TARGETS): AS = $(CC)
|
||||
$(WINDOWS_TARGETS): CFLAGS += \
|
||||
-D_WIN32_WINNT=0x0A00 \
|
||||
-DWINVER=0x0A00 \
|
||||
-DNTDDI_VERSION=NTDDI_WIN10 \
|
||||
-Ideps/openssl/mingw64/include
|
||||
windebug winrelease: LDFLAGS += \
|
||||
-Ideps/openssl/mingw64/usr/local/include
|
||||
$(WINDOWS_TARGETS): LDFLAGS += \
|
||||
-static \
|
||||
-lm \
|
||||
-Ldeps/openssl/mingw64/lib
|
||||
androiddebug androidrelease: CC = $(NDK_PATH)/toolchains/llvm/prebuilt/linux-x86_64/bin/clang
|
||||
androiddebug androidrelease: AS = $(CC)
|
||||
androiddebug androidrelease: CFLAGS += \
|
||||
-target $(NDK_TARGET_TRIPLE)$(NDK_API_VERSION) \
|
||||
-Ideps/openssl/android/arm64-v8a/usr/local/include \
|
||||
-Ldeps/openssl/mingw64/usr/local/lib
|
||||
ifeq ($(UNAME_S),Darwin)
|
||||
$(MACOS_TARGETS): CC = xcrun clang
|
||||
$(IOS_TARGETS): IOS_SYSROOT := $(shell xcrun --sdk iphoneos --show-sdk-path)
|
||||
$(IOS_TARGETS): CC = xcrun --sdk iphoneos clang -isysroot $(IOS_SYSROOT) -arch arm64
|
||||
$(IOSSIM_TARGETS): IOSSIM_SYSROOT := $(shell xcrun --sdk iphonesimulator --show-sdk-path)
|
||||
$(IOSSIM_TARGETS): CC = xcrun --sdk iphonesimulator clang -isysroot $(IOSSIM_SYSROOT) -arch x86_64
|
||||
else ifeq ($(UNAME_S),Linux)
|
||||
$(IOS_TARGETS): IOS_SYSROOT := deps/iPhoneOS17.0.sdk
|
||||
$(IOS_TARGETS): CC = PATH=$$PATH:deps/ios_toolchain/target/bin deps/ios_toolchain/target/bin/arm-apple-darwin11-clang
|
||||
endif
|
||||
$(ANDROID_X86_64_TARGETS): ANDROID_NDK_TARGET_TRIPLE := x86_64-linux-android
|
||||
$(ANDROID_X86_TARGETS): ANDROID_NDK_TARGET_TRIPLE := i686-linux-android
|
||||
$(ANDROID_ARMV7A_TARGETS): ANDROID_NDK_TARGET_TRIPLE := armv7a-linux-androideabi
|
||||
$(ANDROID_ARM64_TARGETS): ANDROID_NDK_TARGET_TRIPLE := aarch64-linux-android
|
||||
$(ANDROID_TARGETS): CC = $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/$(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_MIN_SDK_VERSION)-clang
|
||||
$(ANDROID_TARGETS): AS = $(CC)
|
||||
$(ANDROID_TARGETS): CFLAGS += \
|
||||
-target $(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_MIN_SDK_VERSION) \
|
||||
-Wno-unknown-warning-option
|
||||
androiddebug androidrelease: LDFLAGS += \
|
||||
-target $(NDK_TARGET_TRIPLE)$(NDK_API_VERSION) \
|
||||
-Ldeps/openssl/android/arm64-v8a/usr/local/lib
|
||||
$(ANDROID_ARMV7A_TARGETS): CFLAGS += -Ideps/openssl/android/armeabi-v7a/usr/local/include
|
||||
$(ANDROID_ARMV7A_TARGETS): LDFLAGS += -Ldeps/openssl/android/armeabi-v7a/usr/local/lib
|
||||
$(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_TARGETS): CFLAGS += -Ideps/openssl/android/x86/usr/local/include
|
||||
$(ANDROID_X86_TARGETS): CFLAGS += -Wno-atomic-alignment
|
||||
$(ANDROID_X86_TARGETS): LDFLAGS += -Ldeps/openssl/android/x86/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
|
||||
$(NONMACOS_TARGETS): CFLAGS += -Wno-cast-function-type
|
||||
$(NONMACOS_TARGETS): LDFLAGS += -Wl,--gc-sections
|
||||
$(IOS_TARGETS): CFLAGS += -mios-version-min=9.0 -Ideps/openssl/ios/ios64-xcrun/usr/local/include
|
||||
$(IOS_TARGETS): LDFLAGS += -Ldeps/openssl/ios/ios64-xcrun/usr/local/lib
|
||||
$(IOSSIM_TARGETS): CFLAGS += -Ideps/openssl/ios/iossimulator-xcrun/usr/local/include
|
||||
$(IOSSIM_TARGETS): LDFLAGS += -Ldeps/openssl/ios/iossimulator-xcrun/usr/local/lib
|
||||
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common
|
||||
@ -57,10 +198,14 @@ 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,androiddebug androidrelease,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_android))))) \
|
||||
$(foreach build_type,androiddebug androidrelease,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix)))))
|
||||
$(foreach build_type,androiddebug androidrelease androiddebug-x86 androidrelease-x86 androiddebug-x86_64 androidrelease-x86_64 androiddebug-armv7a androiddebug-armv7a,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_android))))) \
|
||||
$(foreach build_type,androiddebug androidrelease androiddebug-x86 androidrelease-x86 androiddebug-x86_64 androidrelease-x86_64 androiddebug-armv7a androidrelease-armv7a,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix))))) \
|
||||
$(foreach build_type,macosdebug macosrelease iosdebug iosrelease iossimdebug iossimrelease,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_macos))))) \
|
||||
$(foreach build_type,iosdebug iosrelease iossimdebug iossimrelease,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_ios))))) \
|
||||
$(foreach build_type,androiddebug-x86 androidrelease-x86,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_x86)))))
|
||||
|
||||
APP_SOURCES := $(wildcard src/*.c)
|
||||
APP_SOURCES_ios := $(wildcard src/*.m)
|
||||
APP_OBJS := $(call get_objs,APP_SOURCES)
|
||||
$(APP_OBJS): CFLAGS += \
|
||||
-Ideps/base64c/include \
|
||||
@ -69,18 +214,27 @@ $(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
|
||||
ifeq ($(UNAME_M),x86_64)
|
||||
$(filter-out $(BUILD_DIR)/android% $(BUILD_DIR)/macos% $(BUILD_DIR)/ios%,$(APP_OBJS)): CFLAGS += \
|
||||
-fanalyzer
|
||||
endif
|
||||
|
||||
BLOWFISH_SOURCES := \
|
||||
deps/crypt_blowfish/crypt_blowfish.c \
|
||||
deps/crypt_blowfish/crypt_gensalt.c \
|
||||
deps/crypt_blowfish/wrapper.c
|
||||
BLOWFISH_SOURCES_win = \
|
||||
BLOWFISH_SOURCES_win := \
|
||||
deps/crypt_blowfish/x86.S
|
||||
BLOWFISH_SOURCES_x86 := \
|
||||
deps/crypt_blowfish/x86.S
|
||||
BLOWFISH_OBJS := $(call get_objs,BLOWFISH_SOURCES)
|
||||
|
||||
@ -100,13 +254,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 \
|
||||
@ -124,7 +275,6 @@ UV_SOURCES_unix := \
|
||||
deps/libuv/src/unix/tty.c \
|
||||
deps/libuv/src/unix/udp.c
|
||||
UV_SOURCES_android := \
|
||||
deps/libuv/src/unix/pthread-fixes.c \
|
||||
deps/libuv/src/unix/random-getentropy.c
|
||||
UV_SOURCES_win := \
|
||||
deps/libuv/src/win/async.c \
|
||||
@ -152,19 +302,50 @@ UV_SOURCES_win := \
|
||||
deps/libuv/src/win/util.c \
|
||||
deps/libuv/src/win/winapi.c \
|
||||
deps/libuv/src/win/winsock.c
|
||||
UV_SOURCES_macos := \
|
||||
deps/libuv/src/unix/async.c \
|
||||
deps/libuv/src/unix/bsd-ifaddrs.c \
|
||||
deps/libuv/src/unix/core.c \
|
||||
deps/libuv/src/unix/darwin.c \
|
||||
deps/libuv/src/unix/darwin-proctitle.c \
|
||||
deps/libuv/src/unix/dl.c \
|
||||
deps/libuv/src/unix/fs.c \
|
||||
deps/libuv/src/unix/fsevents.c \
|
||||
deps/libuv/src/unix/getaddrinfo.c \
|
||||
deps/libuv/src/unix/getnameinfo.c \
|
||||
deps/libuv/src/unix/kqueue.c \
|
||||
deps/libuv/src/unix/loop-watcher.c \
|
||||
deps/libuv/src/unix/loop.c \
|
||||
deps/libuv/src/unix/pipe.c \
|
||||
deps/libuv/src/unix/poll.c \
|
||||
deps/libuv/src/unix/process.c \
|
||||
deps/libuv/src/unix/proctitle.c \
|
||||
deps/libuv/src/unix/random-devurandom.c \
|
||||
deps/libuv/src/unix/random-getentropy.c \
|
||||
deps/libuv/src/unix/signal.c \
|
||||
deps/libuv/src/unix/stream.c \
|
||||
deps/libuv/src/unix/tcp.c \
|
||||
deps/libuv/src/unix/thread.c \
|
||||
deps/libuv/src/unix/tty.c \
|
||||
deps/libuv/src/unix/udp.c
|
||||
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 := \
|
||||
deps/libsodium/src/libsodium/crypto_aead/aegis128l/aead_aegis128l.c \
|
||||
deps/libsodium/src/libsodium/crypto_aead/aegis128l/aegis128l_soft.c \
|
||||
deps/libsodium/src/libsodium/crypto_aead/aegis256/aead_aegis256.c \
|
||||
deps/libsodium/src/libsodium/crypto_aead/aegis256/aegis256_soft.c \
|
||||
deps/libsodium/src/libsodium/crypto_auth/hmacsha512/auth_hmacsha512.c \
|
||||
deps/libsodium/src/libsodium/crypto_auth/hmacsha512256/auth_hmacsha512256.c \
|
||||
deps/libsodium/src/libsodium/crypto_box/crypto_box.c \
|
||||
@ -172,6 +353,7 @@ SODIUM_SOURCES := \
|
||||
deps/libsodium/src/libsodium/crypto_core/ed25519/ref10/ed25519_ref10.c \
|
||||
deps/libsodium/src/libsodium/crypto_core/hsalsa20/ref2/core_hsalsa20_ref2.c \
|
||||
deps/libsodium/src/libsodium/crypto_core/salsa/ref/core_salsa_ref.c \
|
||||
deps/libsodium/src/libsodium/crypto_core/softaes/softaes.c \
|
||||
deps/libsodium/src/libsodium/crypto_generichash/blake2b/ref/blake2b-compress-ref.c \
|
||||
deps/libsodium/src/libsodium/crypto_generichash/blake2b/ref/blake2b-ref.c \
|
||||
deps/libsodium/src/libsodium/crypto_generichash/blake2b/ref/generichash_blake2b.c \
|
||||
@ -185,6 +367,7 @@ SODIUM_SOURCES := \
|
||||
deps/libsodium/src/libsodium/crypto_pwhash/argon2/blake2b-long.c \
|
||||
deps/libsodium/src/libsodium/crypto_scalarmult/crypto_scalarmult.c \
|
||||
deps/libsodium/src/libsodium/crypto_scalarmult/curve25519/ref10/x25519_ref10.c \
|
||||
deps/libsodium/src/libsodium/crypto_scalarmult/curve25519/sandy2x/curve25519_sandy2x.c \
|
||||
deps/libsodium/src/libsodium/crypto_scalarmult/curve25519/scalarmult_curve25519.c \
|
||||
deps/libsodium/src/libsodium/crypto_secretbox/crypto_secretbox_easy.c \
|
||||
deps/libsodium/src/libsodium/crypto_secretbox/xsalsa20poly1305/secretbox_xsalsa20poly1305.c \
|
||||
@ -198,45 +381,67 @@ SODIUM_SOURCES := \
|
||||
deps/libsodium/src/libsodium/crypto_stream/salsa20/ref/salsa20_ref.c \
|
||||
deps/libsodium/src/libsodium/crypto_stream/salsa20/stream_salsa20.c \
|
||||
deps/libsodium/src/libsodium/crypto_stream/xsalsa20/stream_xsalsa20.c \
|
||||
deps/libsodium/src/libsodium/crypto_verify/sodium/verify.c \
|
||||
deps/libsodium/src/libsodium/crypto_verify/verify.c \
|
||||
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 \
|
||||
-DMINIMAL=1 \
|
||||
-DHAVE_ALLOCA \
|
||||
-DHAVE_CPUID_V \
|
||||
-DHAVE_GCC_MEMORY_FENCES \
|
||||
-Wno-unused-function \
|
||||
-Wno-unused-variable \
|
||||
-Wno-type-limits \
|
||||
-Wno-unknown-pragmas \
|
||||
-Wno-attributes \
|
||||
-Ideps/libsodium/builds/msvc \
|
||||
-Ideps/libsodium/src/libsodium/include/sodium
|
||||
#(SODIUM_OBJS_unix): CFLAGS += \
|
||||
-DHAVE_ALLOCA_H
|
||||
|
||||
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_THREADSAFE=1 \
|
||||
-DSQLITE_MAX_LENGTH=5242880 \
|
||||
-DSQLITE_MAX_SQL_LENGTH=100000 \
|
||||
-DSQLITE_LIKE_DOESNT_MATCH_BLOBS \
|
||||
-DSQLITE_MAX_ATTACHED=1 \
|
||||
-DSQLITE_MAX_COLUMN=100 \
|
||||
-DSQLITE_MAX_EXPR_DEPTH=40 \
|
||||
-DSQLITE_MAX_COMPOUND_SELECT=300 \
|
||||
-DSQLITE_MAX_VDBE_OP=25000 \
|
||||
-DSQLITE_MAX_EXPR_DEPTH=40 \
|
||||
-DSQLITE_MAX_FUNCTION_ARG=8 \
|
||||
-DSQLITE_MAX_ATTACHED=0 \
|
||||
-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=2 \
|
||||
-DSQLITE_UNTESTABLE \
|
||||
-DSQLITE_USE_ALLOCA \
|
||||
-DHAVE_ISNAN \
|
||||
-DHAVE_GETHOSTUUID=0 \
|
||||
-Wno-implicit-fallthrough \
|
||||
-Wno-unused-but-set-variable \
|
||||
-Wno-unused-function
|
||||
-Wno-unused-function \
|
||||
-Wno-unused-variable
|
||||
|
||||
XOPT_SOURCES := deps/xopt/xopt.c
|
||||
XOPT_OBJS := $(call get_objs,XOPT_SOURCES)
|
||||
@ -246,25 +451,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 \
|
||||
@ -285,6 +492,12 @@ LIBBACKTRACE_SOURCES_win := \
|
||||
deps/libbacktrace/pecoff.c \
|
||||
deps/libbacktrace/posix.c \
|
||||
deps/libbacktrace/read.c
|
||||
LIBBACKTRACE_SOURCES_macos := \
|
||||
deps/libbacktrace/dwarf.c \
|
||||
deps/libbacktrace/macho.c \
|
||||
deps/libbacktrace/mmap.c \
|
||||
deps/libbacktrace/mmapio.c \
|
||||
deps/libbacktrace/posix.c
|
||||
LIBBACKTRACE_OBJS := $(call get_objs,LIBBACKTRACE_SOURCES)
|
||||
$(LIBBACKTRACE_OBJS): CFLAGS += \
|
||||
-Ideps/libbacktrace_config \
|
||||
@ -296,29 +509,53 @@ $(LIBBACKTRACE_OBJS): CFLAGS += \
|
||||
PICOHTTPPARSER_SOURCES := \
|
||||
deps/picohttpparser/picohttpparser.c
|
||||
PICOHTTPPARSER_OBJS := $(call get_objs,PICOHTTPPARSER_SOURCES)
|
||||
# $(PICOHTTPPARSER_OBJS): CFLAGS +=
|
||||
|
||||
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
|
||||
debug release: LDFLAGS += \
|
||||
debug release $(MACOS_TARGETS) $(IOS_TARGETS) $(IOSSIM_TARGETS): LDFLAGS += \
|
||||
-ldl \
|
||||
-lssl \
|
||||
-lcrypto
|
||||
windebug winrelease: LDFLAGS += \
|
||||
-lwsock32 \
|
||||
-lws2_32 \
|
||||
-lkernel32 \
|
||||
-liphlpapi \
|
||||
-luserenv \
|
||||
$(WINDOWS_TARGETS): LDFLAGS += \
|
||||
-lssl \
|
||||
-lcrypto \
|
||||
-lcrypt32 \
|
||||
-ldbghelp \
|
||||
-liphlpapi \
|
||||
-lkernel32 \
|
||||
-lole32 \
|
||||
-luserenv \
|
||||
-luuid \
|
||||
-lws2_32 \
|
||||
-lcrypt32
|
||||
androiddebug androidrelease: LDFLAGS += \
|
||||
-lwsock32
|
||||
$(ANDROID_TARGETS): LDFLAGS += \
|
||||
-target $(ANDROID_NDK_TARGET_TRIPLE)$(ANDROID_MIN_SDK_VERSION) \
|
||||
-ldl \
|
||||
-llog \
|
||||
-lssl \
|
||||
-lcrypto
|
||||
$(MACOS_TARGETS) $(IOS_TARGETS) $(IOSSIM_TARGETS): CFLAGS += \
|
||||
-Wno-unknown-warning-option
|
||||
$(IOS_TARGETS) $(IOSSIM_TARGETS): LDFLAGS += \
|
||||
-framework Foundation \
|
||||
-framework CoreFoundation \
|
||||
-framework UIKit \
|
||||
-framework WebKit
|
||||
|
||||
unix: debug release
|
||||
win: windebug winrelease
|
||||
@ -329,6 +566,7 @@ ALL_APP_OBJS := \
|
||||
$(APP_OBJS) \
|
||||
$(BLOWFISH_OBJS) \
|
||||
$(LIBBACKTRACE_OBJS) \
|
||||
$(MINIUNZIP_OBJS) \
|
||||
$(PICOHTTPPARSER_OBJS) \
|
||||
$(QUICKJS_OBJS) \
|
||||
$(SODIUM_OBJS) \
|
||||
@ -352,6 +590,11 @@ $(BUILD_DIR)/$(1)/%.o: %.c
|
||||
@echo [c] $$@
|
||||
@$$(CC) $$(CFLAGS) -c $$< -o $$@
|
||||
|
||||
$(BUILD_DIR)/$(1)/%.o: %.m
|
||||
@mkdir -p $$(dir $$@)
|
||||
@echo [m] $$@
|
||||
@$$(CC) $$(CFLAGS) -c $$< -o $$@
|
||||
|
||||
$(BUILD_DIR)/$(1)/%.o: %.S
|
||||
@mkdir -p $$(dir $$@)
|
||||
@echo [as] $$@
|
||||
@ -360,6 +603,186 @@ 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="[[:digit:]]*"/android:minSdkVersion="$(ANDROID_MIN_SDK_VERSION)"/' \
|
||||
-e 's/android:targetSdkVersion="[[:digit:]]*"/android:targetSdkVersion="$(ANDROID_TARGET_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/
|
||||
|
||||
RAW_FILES := $(filter-out apps/gg% apps/welcome%, $(shell find $(PACKAGE_DIRS) -type f))
|
||||
|
||||
out/apk/TildeFriends-arm-debug.unsigned.apk: BUILD_TYPE := debug
|
||||
out/apk/TildeFriends-arm-release.unsigned.apk: BUILD_TYPE := release
|
||||
out/apk/TildeFriends-x86-debug.unsigned.apk: BUILD_TYPE := debug
|
||||
out/apk/TildeFriends-x86-release.unsigned.apk: BUILD_TYPE := release
|
||||
|
||||
out/apk/TildeFriends-arm-debug.unsigned.apk: out/apk/classes.dex out/androiddebug/tildefriends out/androiddebug-armv7a/tildefriends $(RAW_FILES) out/apk/res.apk
|
||||
out/apk/TildeFriends-arm-release.unsigned.apk: out/apk/classes.dex out/androidrelease/tildefriends out/androidrelease-armv7a/tildefriends $(RAW_FILES) out/apk/res.apk
|
||||
out/apk/TildeFriends-x86-debug.unsigned.apk: out/apk/classes.dex out/androiddebug-x86_64/tildefriends out/androiddebug-x86/tildefriends $(RAW_FILES) out/apk/res.apk
|
||||
out/apk/TildeFriends-x86-release.unsigned.apk: out/apk/classes.dex out/androidrelease-x86_64/tildefriends out/androidrelease-x86/tildefriends $(RAW_FILES) out/apk/res.apk
|
||||
|
||||
out/apk/TildeFriends-arm-%.unsigned.apk:
|
||||
@mkdir -p $(dir $@) out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/ out/apk-arm-$(BUILD_TYPE)/lib/armeabi-v7a/
|
||||
@echo [aapt] $@
|
||||
@cp out/android$(BUILD_TYPE)/tildefriends out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/tildefriends.so
|
||||
@cp out/android$(BUILD_TYPE)-armv7a/tildefriends out/apk-arm-$(BUILD_TYPE)/lib/armeabi-v7a/tildefriends.so
|
||||
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/tildefriends.so
|
||||
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-arm-$(BUILD_TYPE)/lib/armeabi-v7a/tildefriends.so
|
||||
@cp out/apk/res.apk $@
|
||||
@cp out/apk/classes.dex out/apk-arm-$(BUILD_TYPE)/
|
||||
@cd out/apk-arm-$(BUILD_TYPE) && zip -u ../../$@ -q -9 -r . && cd ../../
|
||||
@zip -u $@ -q -9 -x '*.map' --exclude=apps/gg* --exclude=apps/welcome* -r $(PACKAGE_DIRS) $(RAW_FILES)
|
||||
|
||||
out/apk/TildeFriends-x86-%.unsigned.apk:
|
||||
@mkdir -p $(dir $@) out/apk-x86-$(BUILD_TYPE)/lib/x86_64/ out/apk-x86-$(BUILD_TYPE)/lib/x86/
|
||||
@echo [aapt] $@
|
||||
@cp out/android$(BUILD_TYPE)-x86_64/tildefriends out/apk-x86-$(BUILD_TYPE)/lib/x86_64/
|
||||
@cp out/android$(BUILD_TYPE)-x86/tildefriends out/apk-x86-$(BUILD_TYPE)/lib/x86/
|
||||
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-x86-$(BUILD_TYPE)/lib/x86_64/tildefriends
|
||||
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-x86-$(BUILD_TYPE)/lib/x86/tildefriends
|
||||
@cp out/apk/res.apk $@
|
||||
@cp out/apk/classes.dex out/apk-x86-$(BUILD_TYPE)/
|
||||
@cd out/apk-x86-$(BUILD_TYPE) && zip -u ../../$@ -q -9 -r . && cd ../../
|
||||
@zip -u $@ -q -9 -x '*.map' --exclude=apps/gg* --exclude=apps/welcome* -r $(PACKAGE_DIRS) $(RAW_FILES)
|
||||
|
||||
out/%.apk: out/apk/%.unsigned.apk
|
||||
@echo [apksigner] $(notdir $@)
|
||||
@$(ANDROID_BUILD_TOOLS)/apksigner sign --ks .keys/android.jks --ks-key-alias androidKey --ks-pass pass:android --key-pass pass:android --out $@ $<
|
||||
|
||||
release-apk: out/TildeFriends-arm-release.apk out/TildeFriends-x86-release.apk
|
||||
.PHONY: release-apk
|
||||
|
||||
releaseapkgo: out/TildeFriends-arm-release.apk
|
||||
@adb install -r $<
|
||||
@adb shell am start com.unprompted.tildefriends/.MainActivity
|
||||
.PHONY: releaseapkgo
|
||||
|
||||
# iOS Support
|
||||
out/%.app/Info.plist: src/ios/Info.plist
|
||||
@mkdir -p $(dir $@)
|
||||
@cp -v $< $@
|
||||
out/%.app/tildefriends.png: src/ios/tildefriends.png
|
||||
@mkdir -p $(dir $@)
|
||||
@cp -v $< $@
|
||||
|
||||
out/%/data.zip: $(RAW_FILES)
|
||||
@zip -u $@ -q -9 -x '*.map' -r $(PACKAGE_DIRS) $(RAW_FILES)
|
||||
|
||||
out/tildefriends-%.app/tildefriends: out/%/tildefriends out/tildefriends-%.app/Info.plist out/tildefriends-%.app/tildefriends.png out/tildefriends-%.app/data.zip
|
||||
@mkdir -p $(dir $@)
|
||||
@cp -v $< $@
|
||||
ifeq ($(HAVE_LINUX_IOS),1)
|
||||
@zsign -q -k .keys/apple.p12 -f -m src/ios/embedded.mobileprovision $(realpath $(dir $@))
|
||||
endif
|
||||
.SECONDARY:
|
||||
out/tildefriends-%.ipa: out/tildefriends-ios%.app/tildefriends
|
||||
@echo [ipa] $@
|
||||
@rm -rf $@.tmp $@
|
||||
@mkdir -p $@.tmp/Payload/tildefriends.app/
|
||||
@cp -R $(dir $<)/* $@.tmp/Payload/tildefriends.app/
|
||||
@cd $@.tmp/ && zip -u ../../$@ -q -9 -r ./
|
||||
@rm -rf $@.tmp/
|
||||
|
||||
iossimdebug-app: out/tildefriends-iossimdebug.app/tildefriends
|
||||
iossimrelease-app: out/tildefriends-iossimrelease.app/tildefriends
|
||||
iosdebug-app: out/tildefriends-iosdebug.app/tildefriends
|
||||
iosrelease-app: out/tildefriends-iosrelease.app/tildefriends
|
||||
|
||||
iosdebug-ipa: out/tildefriends-debug.ipa
|
||||
iosrelease-ipa: out/tildefriends-release.ipa
|
||||
.PHONY: iossimdebug-app iossimrelease-app iosdebug-app iosrelease-app
|
||||
|
||||
ios%go: out/tildefriends-ios%.app/tildefriends
|
||||
ideviceinstaller -i $(realpath $(dir $<))
|
||||
|
||||
iossimdebuggo: out/tildefriends-iossimdebug.app/tildefriends
|
||||
xcrun simctl install booted out/tildefriends-iossimdebug.app/
|
||||
xcrun simctl launch booted com.unprompted.tildefriends
|
||||
.PHONY: iossimdebuggo
|
||||
|
||||
apklog:
|
||||
@adb logcat *:S tildefriends
|
||||
.PHONY: apklog
|
||||
|
||||
clean:
|
||||
rm -rf $(BUILD_DIR)
|
||||
.PHONY: clean
|
||||
|
||||
dist: release-apk iosrelease-ipa
|
||||
@echo "[export] $$(svn info --show-item url)"
|
||||
@rm -rf tildefriends-$(VERSION_NUMBER)
|
||||
@svn export -q . tildefriends-$(VERSION_NUMBER)
|
||||
@echo "tildefriends-$(VERSION_NUMBER): $(VERSION_NAME)" > tildefriends-$(VERSION_NUMBER)/VERSION
|
||||
@echo "[tar] tildefriends-$(VERSION_NUMBER).tar.xz"
|
||||
@tar \
|
||||
--exclude=apps/gg* \
|
||||
--exclude=apps/welcome* \
|
||||
--exclude=deps/libbacktrace/Isaac.Newton-Opticks.txt \
|
||||
--exclude=deps/libsodium/builds/msvc/vs* \
|
||||
--exclude=deps/libsodium/builds/msvc/build \
|
||||
--exclude=deps/libsodium/builds/msvc/properties \
|
||||
--exclude=deps/libsodium/configure \
|
||||
--exclude=deps/libsodium/test \
|
||||
--exclude=deps/libuv/docs \
|
||||
--exclude=deps/libuv/test \
|
||||
--exclude=deps/openssl \
|
||||
--exclude=deps/speedscope/*.map \
|
||||
--exclude=deps/sqlite/shell.c \
|
||||
--exclude=deps/zlib/contrib/vstudio \
|
||||
--exclude=deps/zlib/doc \
|
||||
-caf tildefriends-$(VERSION_NUMBER).tar.xz tildefriends-$(VERSION_NUMBER)
|
||||
@rm -rf tildefriends-$(VERSION_NUMBER)
|
||||
@echo "[cp] TildeFriends-x86-$(VERSION_NUMBER).apk"
|
||||
@cp out/TildeFriends-x86-release.apk TildeFriends-x86-$(VERSION_NUMBER).apk
|
||||
@echo "[cp] TildeFriends-arm-$(VERSION_NUMBER).apk"
|
||||
@cp out/TildeFriends-arm-release.apk TildeFriends-arm-$(VERSION_NUMBER).apk
|
||||
@echo "[cp] TildeFriends-$(VERSION_NUMBER).ipa"
|
||||
@cp out/tildefriends-release.ipa TildeFriends-$(VERSION_NUMBER).ipa
|
||||
.PHONY: dist
|
||||
|
||||
dist-test: dist
|
||||
@tar -xf tildefriends-$(VERSION_NUMBER).tar.xz
|
||||
@$(MAKE) -C tildefriends-$(VERSION_NUMBER)/ debug release
|
||||
@docker build tildefriends-$(VERSION_NUMBER)/
|
||||
@rm -rf tildefriends-$(VERSION_NUMBER)
|
||||
.PHONY: dist-test
|
||||
|
@ -15,8 +15,9 @@ Scuttlebutt, as well as a platform for writing and running web applications.
|
||||
are kept up to date in the tree.
|
||||
2. To build, run `make debug` or `make release`. An executable will be
|
||||
generated in a subdirectory of `out/`.
|
||||
3. `make windebug` or `make winrelease` will generate a windows executable
|
||||
which might work.
|
||||
3. It's possible to build for Android, iOS, and Windows on Linux, if you have
|
||||
the right dependencies in the right places. `make windebug winrelease
|
||||
iosdebug-ipa iosrelease-ipa apk`.
|
||||
4. To build in docker, `docker build .`.
|
||||
|
||||
## Running
|
||||
@ -28,7 +29,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
|
||||
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app"
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "🎛"
|
||||
}
|
@ -9,14 +9,18 @@ tfrpc.register(function global_settings_set(key, value) {
|
||||
});
|
||||
|
||||
async function main() {
|
||||
let data = {
|
||||
users: {},
|
||||
granted: await core.allPermissionsGranted(),
|
||||
settings: await core.globalSettingsDescriptions(),
|
||||
};
|
||||
for (let user of await core.users()) {
|
||||
data.users[user] = await core.permissionsForUser(user);
|
||||
try {
|
||||
let data = {
|
||||
users: {},
|
||||
granted: await core.allPermissionsGranted(),
|
||||
settings: await core.globalSettingsDescriptions(),
|
||||
};
|
||||
for (let user of await core.users()) {
|
||||
data.users[user] = await core.permissionsForUser(user);
|
||||
}
|
||||
await app.setDocument(utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data)));
|
||||
} catch {
|
||||
await app.setDocument('<span style="color: #f00">Only an administrator can modify these settings.</span>');
|
||||
}
|
||||
await app.setDocument(utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data)));
|
||||
}
|
||||
main();
|
@ -1,9 +1,9 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<html style="width: 100%">
|
||||
<head>
|
||||
<script>const g_data = $data;</script>
|
||||
</head>
|
||||
<body style="color: #fff">
|
||||
<body style="color: #fff; width: 100%">
|
||||
<h1>Tilde Friends Administration</h1>
|
||||
</body>
|
||||
<script type="module" src="script.js"></script>
|
||||
|
@ -25,29 +25,37 @@ window.addEventListener('load', function() {
|
||||
function input_template(key, description) {
|
||||
if (description.type === 'boolean') {
|
||||
return html`
|
||||
<label ?for=${'gs_' + key} style="grid-column: 1">${key}: </label>
|
||||
<input type="checkbox" ?checked=${description.value} ?id=${'gs_' + key} style="grid-column: 2"></input>
|
||||
<div style="grid-column: 3">
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.parentElement.previousElementSibling.checked)}>Set</button>
|
||||
<span>${description.description}</span>
|
||||
<div style="margin-top: 1em">
|
||||
<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
|
||||
<div>
|
||||
<input type="checkbox" ?checked=${description.value} id=${'gs_' + key}></input>
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.checked)}>Set</button>
|
||||
<div>${description.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else if (description.type === 'textarea') {
|
||||
return html`
|
||||
<label ?for=${'gs_' + key} style="grid-column: 1">${key}: </label>
|
||||
<textarea style="vertical-align: top" rows=20 cols=80 ?id=${'gs_' + key}>${description.value}</textarea>
|
||||
<div style="grid-column: 3">
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.parentElement.previousElementSibling.value)}>Set</button>
|
||||
<span>${description.description}</span>
|
||||
<div style="margin-top: 1em"">
|
||||
<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
|
||||
<div style="width: 100%; padding: 0; margin: 0">
|
||||
<div style="width: 90%; padding: 0 margin: 0">
|
||||
<textarea style="vertical-align: top; width: 100%" rows=20 cols=80 id=${'gs_' + key}>${description.value}</textarea>
|
||||
</div>
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.firstElementChild.value)}>Set</button>
|
||||
<div>${description.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
return html`
|
||||
<label ?for=${'gs_' + key} style="grid-column: 1">${key}: </label>
|
||||
<input type="text" value="${description.value}" ?id=${'gs_' + key}></input>
|
||||
<div style="grid-column: 3">
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.parentElement.previousElementSibling.value)}>Set</button>
|
||||
<span>${description.description}</span>
|
||||
<div style="margin-top: 1em">
|
||||
<label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
|
||||
<div>
|
||||
<input type="text" value="${description.value}" id=${'gs_' + key}></input>
|
||||
<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.value)}>Set</button>
|
||||
<div>${description.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
@ -67,12 +75,13 @@ window.addEventListener('load', function() {
|
||||
${Object.entries(users).map(u => user_template(u[0], u[1]))}
|
||||
</ul>`;
|
||||
const page_template = (data) =>
|
||||
html`<div>
|
||||
<h2>Global Settings</h2>
|
||||
<div style="display: grid">
|
||||
${Object.keys(data.settings).sort().map(x => html`${input_template(x, data.settings[x])}`)}
|
||||
html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%">
|
||||
<h2>Global Settings</h2>
|
||||
<div>
|
||||
${Object.keys(data.settings).sort().map(x => html`${input_template(x, data.settings[x])}`)}
|
||||
</div>
|
||||
${users_template(data.users)}
|
||||
</div>
|
||||
${users_template(data.users)}
|
||||
</div>`;
|
||||
`;
|
||||
render(page_template(g_data), document.body);
|
||||
});
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app"
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "📜"
|
||||
}
|
File diff suppressed because one or more lines are too long
356
apps/api/docs.js
Normal file
356
apps/api/docs.js
Normal file
@ -0,0 +1,356 @@
|
||||
export const docs = {};
|
||||
|
||||
docs.global = `# Tilde Friends API Documentation
|
||||
|
||||
Welcome to the Tilde Friends API documentation.
|
||||
|
||||
* [App Globals](#App_Globals)
|
||||
* [Database Interface](#Database)
|
||||
* [Remote Procedure Calls](#tfrpc)
|
||||
|
||||
<a id="App_Globals"></a>
|
||||
## <span style="color: #aaf">App Globals</span>
|
||||
The following are functions and values exposed to all apps in their \`app.js\` or \`handler.js\`. Most
|
||||
of these are asynchronous, returning a \`Promise\` that will be resolved when the call completes, unless
|
||||
noted otherwise.
|
||||
|
||||
This is all a work in progess. These are liable to change without warning. Feedback is welcome.
|
||||
|
||||
The exposed functions in this API balance multiple competing needs:
|
||||
* The surface area of the exposed API ought to be fairly minimal. If something can be implemented entirely app-side, that is
|
||||
generally preferred over building it into the core.
|
||||
* Everything is built on this API. Ideally the admin app, the SSB app, and the editor all use standard API exposed to all
|
||||
apps, with appropriate permission guards in place making it so that only trusted apps do potentially destructive operations.
|
||||
There will be some things here that aren't necessarily general use to support what's required.
|
||||
|
||||
If you are looking at the [Tilde Friends source code](https://www.tildefriends.net/~cory/releases/),
|
||||
the vast majority of these are implemented in \`src/*.js.c\` files, and exposed to apps via \`core/core.js\`.
|
||||
`;
|
||||
|
||||
docs['core.user.credentials.session.name'] = `
|
||||
*String* The name of the authenticated user.
|
||||
`;
|
||||
|
||||
docs['app.setDocument()'] = `
|
||||
Set the contents of the client <iframe/>.
|
||||
### Parameters
|
||||
* *String* **html** The HTML contents.
|
||||
`;
|
||||
|
||||
docs['core.apps()'] = `
|
||||
Gets a list of apps owned by the current user.
|
||||
### Returns
|
||||
*Array* An array of string names of the apps owned by the current user.
|
||||
`;
|
||||
|
||||
docs['core.url'] = `
|
||||
The url by which the running app is being invoked.
|
||||
`;
|
||||
|
||||
docs['app.localStorageSet()'] = `
|
||||
Set a value in browser local storage.
|
||||
### Parameters
|
||||
*String* **key** The localStorage key to set.
|
||||
*String* **value** The localStorage value to set.
|
||||
`;
|
||||
|
||||
docs['app.localStorageGet()'] = `
|
||||
Gets a value from browser local storage.
|
||||
### Parameters
|
||||
*String* **key** The key with which the value was set.
|
||||
### Returns
|
||||
*String* The value, or undefined.
|
||||
`;
|
||||
|
||||
docs['app.print()'] = `
|
||||
Log information for debugging purposes to the server and to the connected browser console.
|
||||
### Parameters
|
||||
* ... Any args to print.
|
||||
`;
|
||||
|
||||
docs['ssb.createIdentity()'] = `
|
||||
Create a new SSB identity.
|
||||
### Returns
|
||||
*String* The created identity public key.
|
||||
`;
|
||||
|
||||
docs['ssb.getIdentities()'] = `
|
||||
Get all SSB identities owned by the current user.
|
||||
### Returns
|
||||
*Array* An array of public key strings.
|
||||
`;
|
||||
|
||||
docs['ssb.sqlAsync()'] = `
|
||||
Run an SQL query against the sqlite database.
|
||||
### Parameters
|
||||
* *String* **query** The sqlite query.
|
||||
* *Array* **args** The query arguments to bind.
|
||||
* *Function* **callback** Callback called for each row result.
|
||||
`;
|
||||
|
||||
docs['ssb.appendMessageWithIdentity()'] = `
|
||||
Signs and stores a message in the SSB database.
|
||||
### Parameters
|
||||
* *String* **id** The public key of an SSB identity owned by the authenticated user.
|
||||
* *Object* **message** The unsigned message.
|
||||
`;
|
||||
|
||||
docs['ssb.storeMessage()'] = `
|
||||
Verifies and stores a signed message in the SSB database.
|
||||
### Parameters
|
||||
* *Object* **message** The valid, signed message to store.
|
||||
`;
|
||||
|
||||
docs['ssb.blobStore()'] = `
|
||||
Store a blob in the SSB database.
|
||||
### Parameters
|
||||
* *String*/*Uint8Array* **blob** The blob contents to store
|
||||
### Returns
|
||||
*String* The stored blob ID.
|
||||
`;
|
||||
|
||||
docs['ssb.blobGet()'] = `
|
||||
Fetches a blob from the database.
|
||||
### Parameters
|
||||
* *String* **blob_id** The blob identifier to fetch (\`&....sha256\`).
|
||||
### Returns
|
||||
*ArrayBuffer* The blob data.
|
||||
`;
|
||||
|
||||
docs['print()'] = `
|
||||
Log debug information both to the server's console and to the visiting user's browser console when possible.
|
||||
### Parameters
|
||||
* **...** Whatever you want to log. Will be joined with spaces.
|
||||
`;
|
||||
|
||||
docs['database()'] = `
|
||||
Returns a database instance that is specific to the authenticated user and the given key.
|
||||
### Parameters
|
||||
* *String* **key** The database key.
|
||||
### Returns
|
||||
*Database* A database.
|
||||
`;
|
||||
|
||||
docs['my_shared_database()'] = `
|
||||
Returns a database instance that is specific to the authenticated user and the given key.
|
||||
### Parameters
|
||||
* *String* **package_name** The database package name.
|
||||
* *String* **key** The database key.
|
||||
### Returns
|
||||
*Database* A database.
|
||||
`;
|
||||
|
||||
docs['shared_database()'] = `
|
||||
Returns a database instance that is shared between all users of the app, determined by its owner and app name.
|
||||
### Parameters
|
||||
* *String* **key** The database key.
|
||||
### Returns
|
||||
*Database* A database.
|
||||
`;
|
||||
|
||||
docs['base64Decode()'] = `
|
||||
Decode a base64 string to bytes.
|
||||
|
||||
Completes synchronously.
|
||||
### Parameters
|
||||
* *String* value The base64-encoded string.
|
||||
### Returns
|
||||
*Uint8Array* The decoded bytes.
|
||||
`;
|
||||
|
||||
docs['base64Encode()'] = `
|
||||
Encode bytes to a base64 string.
|
||||
|
||||
Completes synchronously.
|
||||
### Parameters
|
||||
* *Uint8Array* The bytes to encode.
|
||||
### Returns
|
||||
*String* The base64-encoded string.
|
||||
`;
|
||||
|
||||
docs['utf8Decode()'] = `
|
||||
Decode UTF-8 bytes to a string.
|
||||
|
||||
Completes synchronously.
|
||||
### Parameters
|
||||
* *Uint8Array* **value** The value to decode.
|
||||
### Returns
|
||||
*String* The value as a string.
|
||||
`;
|
||||
|
||||
docs['utf8Encode()'] = `
|
||||
Encodes a string to UTF-8 bytes.
|
||||
|
||||
Completes synchronously.
|
||||
### Parameters
|
||||
* *String* **value** The value to encode.
|
||||
### Returns
|
||||
*Uint8Array* The encoded \`value\`.
|
||||
`;
|
||||
|
||||
docs['setTimeout()'] = `
|
||||
Call a function after some delay.
|
||||
### Parameters
|
||||
* *Function* **callback** The function to call.
|
||||
* *Number* **timeout** Number of milliseconds to wait before calling the callback function.
|
||||
`;
|
||||
|
||||
docs['parseHttpRequest()'] = `
|
||||
Parses an HTTP request.
|
||||
### Parameters
|
||||
* *Uint8Array* **request** The request data. Maybe be partial or contain extra data. The return value will
|
||||
indicate when and where it is complete.
|
||||
* *Number* **last_length** The length of the data passed on a previous attempt for the same request, or 0 initially.
|
||||
### Returns
|
||||
* *Integer* **-2** if the request is incomplete.
|
||||
* *Integer* **-1** if the request could not be parsed.
|
||||
* *Object* An object with **bytes_parsed**, **minor_version**, **path**, and **headers** fields on successful parse.
|
||||
`;
|
||||
|
||||
docs['parseHttpResponse()'] = `
|
||||
Parses an HTTP response.
|
||||
### Parameters
|
||||
* *Uint8Array* **response** The response data. Maybe be partial or contain extra data. The return value will
|
||||
indicate when and where it is complete.
|
||||
* *Number* **last_length** The length of the data passed on a previous attempt for the same response, or 0 initially.
|
||||
### Returns
|
||||
* *Integer* **-2** if the response is incomplete.
|
||||
* *Integer* **-1** if the response could not be parsed.
|
||||
* *Object* An object with **bytes_parsed**, **minor_version**, **status**, **message**, and **headers** fields on successful parse.
|
||||
`;
|
||||
|
||||
docs['sha1Digest()'] =`
|
||||
Calculates a SHA1 digest.
|
||||
|
||||
Completes synchronously.
|
||||
### Parameters
|
||||
* *String* **value** The value for which to calculate the digest.
|
||||
### Returns
|
||||
*String* The SHA1 digest of UTF-8 encoded \`value\`.
|
||||
`;
|
||||
|
||||
docs['maskBytes()'] = `
|
||||
Masks bytes for WebSocket communication.
|
||||
|
||||
Completes synchronously.
|
||||
### Parameters
|
||||
* *Uint8Array* **bytes** The byte array of data to mask.
|
||||
* *Uint32* **mask** The mask to apply.
|
||||
### Returns
|
||||
*Uint32Array* The masked bytes.
|
||||
`;
|
||||
|
||||
docs['exit()'] = `
|
||||
Exits the app. But why would you want to do that?
|
||||
|
||||
Completes synchronously.
|
||||
### Parameters
|
||||
* *Integer* **exit_code** System exit code.
|
||||
`;
|
||||
|
||||
docs['version()'] = `
|
||||
Gets version information for the running server.
|
||||
### Returns
|
||||
*Object* Keys are things like \`name\` and \`number\` for the server itself and \`libuv\` and \`openssl\` for
|
||||
dependencies. Values are *String* version numbers.
|
||||
`;
|
||||
|
||||
docs['platform()'] = `
|
||||
Gets the host operating system platform of the running server.
|
||||
### Returns
|
||||
*String* The platform, one of \`windows\`, \`android\`, \`linux\`, or \`other\`.
|
||||
`;
|
||||
|
||||
docs['getFile()'] = `
|
||||
Gets a file from the running app.
|
||||
### Parameters
|
||||
* *String* **name** Name of the file to retrieve.
|
||||
### Returns
|
||||
*Uint8Array* The contents of a file from the app with the given name, or *undefined*.
|
||||
`;
|
||||
|
||||
docs.database = `
|
||||
# <span style="color: #aaf">Database</span>
|
||||
Local-only storage is provided by a \`Database\` type representing a key-value store.
|
||||
`;
|
||||
|
||||
docs['database.get()'] = `
|
||||
Gets a value from the database.
|
||||
### Parameters
|
||||
* *String* **key** The key.
|
||||
### Returns
|
||||
*String* The value from the database or undefined if not found.
|
||||
`;
|
||||
|
||||
docs['database.getAll()'] = `
|
||||
Gets all keys from the database.
|
||||
### Returns
|
||||
*Array* An array of *String* key names for all keys in the given database.
|
||||
`;
|
||||
|
||||
docs['database.getLike()'] = `
|
||||
Gets all keys and values from the database matching a pattern.
|
||||
### Parameters
|
||||
* *String* **pattern** An sqlite \`LIKE\` pattern to match keys against.
|
||||
### Returns
|
||||
*Object* An object whose keys are the database keys and values are the database values that match the given pattern.
|
||||
`;
|
||||
|
||||
docs['database.set()'] = `
|
||||
Sets a value in the database, creating a new entry or replacing an existing entry.
|
||||
### Parameters
|
||||
* *String* **key** The key.
|
||||
* *String* **value** The value.
|
||||
`;
|
||||
|
||||
docs['database.exchange()'] = `
|
||||
Performs an atomic compare and exchange operation, setting a value in the database only if its current value matches what is expected.
|
||||
### Parameters
|
||||
* *String* **key** The key.
|
||||
* *String* **expected** The expected value.
|
||||
* *String* **value** The new value.
|
||||
### Returns
|
||||
*Boolean* true if the value is now the given value.
|
||||
`;
|
||||
|
||||
docs['database.remove()'] = `
|
||||
Removes an entry from the database if it exists.
|
||||
### Parameters
|
||||
* *String* **key** The key.
|
||||
`;
|
||||
|
||||
docs.tfrpc = `
|
||||
# <span style="color: #aaf" id="tfrpc">tfrpc</span>
|
||||
\`tfrpc.js\` is a small helper script that is available to be used to facilitate communication between parts of an application.
|
||||
|
||||
\`tfrpc.js\` can be used to asynchronously make calls between the app code running in a sandboxed iframe in the browser
|
||||
and the app process on the server.
|
||||
|
||||
From \`app.js\`:
|
||||
\`\`\`
|
||||
import * as tfrpc from '/tfrpc.js';
|
||||
\`\`\`
|
||||
|
||||
|
||||
From script running in the browser:
|
||||
\`\`\`
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
\`\`\`
|
||||
|
||||
Either side can register or call functions, though they must be registered before they can be called. Arguments and return
|
||||
values are ultimately serialized by means that attempt to preserve most JSON-serializable values as well as functions themselves.
|
||||
`;
|
||||
|
||||
docs['tfrpc.register()'] = `
|
||||
Register a function, allowing it to be called remotely.
|
||||
### Parameters
|
||||
* *Function* **function** The function to register. Its name will be how it will be called.
|
||||
`;
|
||||
|
||||
docs['tfrpc.rpc.*()'] = `
|
||||
Call a remote function.
|
||||
### Parameters
|
||||
* **...** Parameters to pass to the function.
|
||||
### Returns
|
||||
The return value of the called function.
|
||||
`;
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app"
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "💻"
|
||||
}
|
@ -1,31 +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 core.apps();
|
||||
var core_apps = await core.apps('core');
|
||||
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>Apps</h1>
|
||||
<ul id="apps"></ul>
|
||||
<h1 id="apps_title">Apps</h1>
|
||||
<div id="apps" class="container"></div>
|
||||
<h1>Core Apps</h1>
|
||||
<ul id="core_apps"></ul>
|
||||
<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()) {
|
||||
var li = list.appendChild(document.createElement('li'));
|
||||
var a = document.createElement('a');
|
||||
a.innerText = app;
|
||||
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';
|
||||
li.appendChild(a);
|
||||
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>`
|
||||
</html>`;
|
||||
app.setDocument(doc);
|
||||
}
|
||||
|
||||
main();
|
||||
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,3 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app"
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "💽"
|
||||
}
|
@ -20,7 +20,7 @@ async function database_list() {
|
||||
}
|
||||
populate_dbs('dbs', ${JSON.stringify(dbs)});
|
||||
</script>
|
||||
</html>`
|
||||
</html>`;
|
||||
app.setDocument(doc);
|
||||
}
|
||||
|
||||
@ -47,7 +47,7 @@ async function key_list(db) {
|
||||
}
|
||||
populate_dbs('keys', ${JSON.stringify(object)});
|
||||
</script>
|
||||
</html>`
|
||||
</html>`;
|
||||
app.setDocument(doc);
|
||||
}
|
||||
|
||||
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app"
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "📚"
|
||||
}
|
@ -16,9 +16,7 @@
|
||||
- / => Something good.
|
||||
- update docs
|
||||
- audit + document API exposed to apps
|
||||
- sqlStream => sqlExec or something
|
||||
- fix weird HTTP warnings
|
||||
- ssb from child process?
|
||||
- channels
|
||||
- placeholder/missing images
|
||||
- no denial of service
|
||||
@ -57,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
|
||||
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app"
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "➡️"
|
||||
}
|
@ -1,75 +1,163 @@
|
||||
"use strict";
|
||||
let g_about_cache = {};
|
||||
|
||||
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 query(sql, args) {
|
||||
let result = [];
|
||||
await ssb.sqlAsync(sql, args, function(row) {
|
||||
result.push(row);
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async function followingDeep(db, seed_ids, depth) {
|
||||
if (depth <= 0) {
|
||||
return seed_ids;
|
||||
async function contacts_internal(id, last_row_id, following, max_row_id) {
|
||||
let result = Object.assign({}, following[id] || {});
|
||||
result.following = result.following || {};
|
||||
result.blocking = result.blocking || {};
|
||||
let contacts = await query(
|
||||
`
|
||||
SELECT content FROM messages
|
||||
WHERE author = ? AND
|
||||
rowid > ? AND
|
||||
rowid <= ? AND
|
||||
json_extract(content, '$.type') = 'contact'
|
||||
ORDER BY sequence
|
||||
`,
|
||||
[id, last_row_id, max_row_id]);
|
||||
for (let row of contacts) {
|
||||
let contact = JSON.parse(row.content);
|
||||
if (contact.following === true) {
|
||||
result.following[contact.contact] = true;
|
||||
} else if (contact.following === false) {
|
||||
delete result.following[contact.contact];
|
||||
} else if (contact.blocking === true) {
|
||||
result.blocking[contact.contact] = true;
|
||||
} else if (contact.blocking === false) {
|
||||
delete result.blocking[contact.contact];
|
||||
}
|
||||
}
|
||||
var key = JSON.stringify([seed_ids, depth]);
|
||||
if (g_following_deep_cache[key]) {
|
||||
return g_following_deep_cache[key];
|
||||
following[id] = result;
|
||||
return result;
|
||||
}
|
||||
|
||||
async function contact(id, last_row_id, following, max_row_id) {
|
||||
return await contacts_internal(id, last_row_id, following, max_row_id);
|
||||
}
|
||||
|
||||
async function following_deep_internal(ids, depth, blocking, last_row_id, following, max_row_id) {
|
||||
let contacts = await Promise.all([...new Set(ids)].map(x => contact(x, last_row_id, following, max_row_id)));
|
||||
let result = {};
|
||||
for (let i = 0; i < ids.length; i++) {
|
||||
let id = ids[i];
|
||||
let contact = contacts[i];
|
||||
let all_blocking = Object.assign({}, contact.blocking, blocking);
|
||||
let found = Object.keys(contact.following).filter(y => !all_blocking[y]);
|
||||
let deeper = depth > 1 ? await following_deep_internal(found, depth - 1, all_blocking, last_row_id, following, max_row_id) : [];
|
||||
result[id] = [id, ...found, ...deeper];
|
||||
}
|
||||
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;
|
||||
return [...new Set(Object.values(result).flat())];
|
||||
}
|
||||
|
||||
async function following_deep(ids, depth, blocking) {
|
||||
let db = await database('cache');
|
||||
const k_cache_version = 5;
|
||||
let cache = await db.get('following');
|
||||
cache = cache ? JSON.parse(cache) : {};
|
||||
if (cache.version !== k_cache_version) {
|
||||
cache = {
|
||||
version: k_cache_version,
|
||||
following: {},
|
||||
last_row_id: 0,
|
||||
};
|
||||
}
|
||||
let max_row_id = (await query(`
|
||||
SELECT MAX(rowid) AS max_row_id FROM messages
|
||||
`, []))[0].max_row_id;
|
||||
let result = await following_deep_internal(ids, depth, blocking, cache.last_row_id, cache.following, max_row_id);
|
||||
cache.last_row_id = max_row_id;
|
||||
let store = JSON.stringify(cache);
|
||||
await db.set('following', store);
|
||||
return result;
|
||||
}
|
||||
|
||||
async function fetch_about(db, ids, users) {
|
||||
const k_cache_version = 1;
|
||||
let cache = await db.get('about');
|
||||
cache = cache ? JSON.parse(cache) : {};
|
||||
if (cache.version !== k_cache_version) {
|
||||
cache = {
|
||||
version: k_cache_version,
|
||||
about: {},
|
||||
last_row_id: 0,
|
||||
};
|
||||
}
|
||||
let max_row_id = 0;
|
||||
await ssb.sqlAsync(`
|
||||
SELECT MAX(rowid) AS max_row_id FROM messages
|
||||
`,
|
||||
[],
|
||||
function(row) {
|
||||
max_row_id = row.max_row_id;
|
||||
});
|
||||
for (let id of Object.keys(cache.about)) {
|
||||
if (ids.indexOf(id) == -1) {
|
||||
delete cache.about[id];
|
||||
}
|
||||
}
|
||||
|
||||
let abouts = [];
|
||||
await ssb.sqlAsync(
|
||||
`
|
||||
SELECT
|
||||
messages.*
|
||||
FROM
|
||||
messages,
|
||||
json_each(?1) AS following
|
||||
WHERE
|
||||
messages.author = following.value AND
|
||||
messages.rowid > ?3 AND
|
||||
messages.rowid <= ?4 AND
|
||||
json_extract(messages.content, '$.type') = 'about'
|
||||
UNION
|
||||
SELECT
|
||||
messages.*
|
||||
FROM
|
||||
messages,
|
||||
json_each(?2) AS following
|
||||
WHERE
|
||||
messages.author = following.value AND
|
||||
messages.rowid <= ?4 AND
|
||||
json_extract(messages.content, '$.type') = 'about'
|
||||
ORDER BY messages.author, messages.sequence
|
||||
`,
|
||||
[
|
||||
JSON.stringify(ids.filter(id => cache.about[id])),
|
||||
JSON.stringify(ids.filter(id => !cache.about[id])),
|
||||
cache.last_row_id,
|
||||
max_row_id,
|
||||
]);
|
||||
for (let about of abouts) {
|
||||
let content = JSON.parse(about.content);
|
||||
if (content.about === about.author) {
|
||||
delete content.type;
|
||||
delete content.about;
|
||||
cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content);
|
||||
}
|
||||
}
|
||||
cache.last_row_id = max_row_id;
|
||||
await db.set('about', JSON.stringify(cache));
|
||||
users = users || {};
|
||||
for (let id of Object.keys(cache.about)) {
|
||||
users[id] = Object.assign(users[id] || {}, cache.about[id]);
|
||||
}
|
||||
return Object.assign({}, users);
|
||||
}
|
||||
|
||||
async function getAbout(db, id) {
|
||||
if (g_about_cache[id]) {
|
||||
return g_about_cache[id];
|
||||
}
|
||||
var o = await db.get(id + ":about");
|
||||
let o = await db.get(id + ":about");
|
||||
const k_version = 4;
|
||||
var f = o ? JSON.parse(o) : o;
|
||||
let f = o ? JSON.parse(o) : o;
|
||||
if (!f || f.version != k_version) {
|
||||
f = {about: {}, sequence: 0, version: k_version};
|
||||
}
|
||||
@ -89,7 +177,7 @@ async function getAbout(db, id) {
|
||||
function(row) {
|
||||
f.sequence = row.sequence;
|
||||
if (row.content) {
|
||||
var about = {};
|
||||
let about = {};
|
||||
try {
|
||||
about = JSON.parse(row.content);
|
||||
} catch {
|
||||
@ -99,7 +187,7 @@ async function getAbout(db, id) {
|
||||
f.about = Object.assign(f.about, about);
|
||||
}
|
||||
});
|
||||
var j = JSON.stringify(f);
|
||||
let j = JSON.stringify(f);
|
||||
if (o != j) {
|
||||
await db.set(id + ":about", j);
|
||||
}
|
||||
@ -110,7 +198,7 @@ async function getAbout(db, id) {
|
||||
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",
|
||||
"SELECT (LENGTH(author) * COUNT(*) + SUM(LENGTH(content)) + SUM(LENGTH(id))) AS size FROM messages WHERE author = ?1",
|
||||
[id],
|
||||
function (row) {
|
||||
size += row.size;
|
||||
@ -118,6 +206,25 @@ async function getSize(db, id) {
|
||||
return size;
|
||||
}
|
||||
|
||||
|
||||
async function getSizes(ids) {
|
||||
let sizes = {};
|
||||
await ssb.sqlAsync(
|
||||
`
|
||||
SELECT
|
||||
author,
|
||||
(SUM(LENGTH(content)) + SUM(LENGTH(author)) + SUM(LENGTH(messages.id))) AS size
|
||||
FROM messages
|
||||
JOIN json_each(?) AS ids ON author = ids.value
|
||||
GROUP BY author
|
||||
`,
|
||||
[JSON.stringify(ids)],
|
||||
function (row) {
|
||||
sizes[row.author] = row.size;
|
||||
});
|
||||
return sizes;
|
||||
}
|
||||
|
||||
function niceSize(bytes) {
|
||||
let value = bytes;
|
||||
let unit = 'B';
|
||||
@ -133,27 +240,28 @@ function niceSize(bytes) {
|
||||
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;
|
||||
function escape(value) {
|
||||
return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
|
||||
}
|
||||
|
||||
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);
|
||||
let db = await database('ssb');
|
||||
let whoami = await ssb.getIdentities();
|
||||
let tree = '';
|
||||
await app.setDocument(`<pre style="color: #fff">Enumerating followed users...</pre>`);
|
||||
let following = await following_deep(whoami, 2, {});
|
||||
await app.setDocument(`<pre style="color: #fff">Getting names and sizes...</pre>`);
|
||||
let [about, sizes] = await Promise.all([
|
||||
fetch_about(db, following, {}),
|
||||
getSizes(following),
|
||||
]);
|
||||
await app.setDocument(`<pre style="color: #fff">Finishing...</pre>`);
|
||||
following.sort((a, b) => ((sizes[b] ?? 0) - (sizes[a] ?? 0)));
|
||||
for (let id of following) {
|
||||
tree += `<li><a href="/~core/ssb/#${id}">${escape(about[id]?.name ?? id)}</a> ${niceSize(sizes[id] ?? 0)}</li>\n`;
|
||||
}
|
||||
await app.setDocument('<pre style="color: #fff">FOLLOWING:\n' + tree + '</pre>');
|
||||
await app.setDocument('<!DOCTYPE html>\n<html>\n<body style="color: #fff"><h1>Following</h1>\n<ul>' + tree + '</ul>\n</body>\n</html>');
|
||||
}
|
||||
|
||||
main();
|
5
apps/gg.json
Normal file
5
apps/gg.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "🗺",
|
||||
"previous": "&0XSp+xdQwVtQ88bXzvWdH15Ex63hv5zUKTa4zx7HBGM=.sha256"
|
||||
}
|
80
apps/gg/app.js
Normal file
80
apps/gg/app.js
Normal file
@ -0,0 +1,80 @@
|
||||
import * as tfrpc from '/tfrpc.js';
|
||||
import * as strava from './strava.js';
|
||||
|
||||
let g_database;
|
||||
let g_shared_database;
|
||||
|
||||
tfrpc.register(async function createIdentity() {
|
||||
return ssb.createIdentity();
|
||||
});
|
||||
tfrpc.register(async function appendMessage(id, message) {
|
||||
print('APPEND', JSON.stringify(message));
|
||||
return ssb.appendMessageWithIdentity(id, message);
|
||||
});
|
||||
tfrpc.register(function url() {
|
||||
return core.url;
|
||||
});
|
||||
tfrpc.register(async function getUser() {
|
||||
return core.user;
|
||||
});
|
||||
tfrpc.register(function getIdentities() {
|
||||
return ssb.getIdentities();
|
||||
});
|
||||
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 databaseRemove(key, value) {
|
||||
return g_database ? g_database.remove(key, value) : undefined;
|
||||
});
|
||||
tfrpc.register(async function sharedDatabaseGet(key) {
|
||||
return g_shared_database ? g_shared_database.get(key) : undefined;
|
||||
});
|
||||
tfrpc.register(async function sharedDatabaseSet(key, value) {
|
||||
return g_shared_database ? g_shared_database.set(key, value) : undefined;
|
||||
});
|
||||
tfrpc.register(async function sharedDatabaseRemove(key, value) {
|
||||
return g_shared_database ? g_shared_database.remove(key, value) : undefined;
|
||||
});
|
||||
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 (typeof(blob) == 'string') {
|
||||
blob = utf8Encode(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(strava.refresh_token);
|
||||
|
||||
async function main() {
|
||||
g_shared_database = await shared_database('state');
|
||||
if (core.user.credentials?.session?.name) {
|
||||
g_database = await database('state');
|
||||
}
|
||||
|
||||
let attempt;
|
||||
if (core.user.credentials?.session?.name) {
|
||||
let shared_db = await shared_database('state');
|
||||
attempt = await shared_db.get(core.user.credentials.session.name);
|
||||
}
|
||||
app.setDocument(utf8Decode(getFile('index.html')).replace('${data}', JSON.stringify({
|
||||
attempt: attempt,
|
||||
state: core.user?.credentials?.session?.name,
|
||||
})));
|
||||
}
|
||||
|
||||
main();
|
1
apps/gg/emojis.json
Normal file
1
apps/gg/emojis.json
Normal file
File diff suppressed because one or more lines are too long
81
apps/gg/gpx.js
Normal file
81
apps/gg/gpx.js
Normal file
@ -0,0 +1,81 @@
|
||||
function xml_parse(xml) {
|
||||
let result;
|
||||
let path = [];
|
||||
let tag_begin;
|
||||
let text_begin;
|
||||
for (let i = 0; i < xml.length; i++) {
|
||||
let c = xml.charAt(i);
|
||||
if (!tag_begin && c == '<') {
|
||||
if (i > text_begin && path.length) {
|
||||
let value = xml.substring(text_begin, i);
|
||||
if (!/^\s*$/.test(value)) {
|
||||
path[path.length - 1].value = value;
|
||||
}
|
||||
}
|
||||
tag_begin = i + 1;
|
||||
} else if (tag_begin && c == '>') {
|
||||
let tag = xml.substring(tag_begin, i).trim();
|
||||
if (tag.startsWith('?') && tag.endsWith('?')) {
|
||||
/* Ignore directives. */
|
||||
} else if (tag.startsWith('/')) {
|
||||
path.pop();
|
||||
} else {
|
||||
let parts = tag.split(' ');
|
||||
let attributes = {};
|
||||
for (let j = 1; j < parts.length; j++) {
|
||||
let eq = parts[j].indexOf('=');
|
||||
let value = parts[j].substring(eq + 1);
|
||||
if (value.startsWith('"') && value.endsWith('"')) {
|
||||
value = value.substring(1, value.length - 1);
|
||||
}
|
||||
attributes[parts[j].substring(0, eq)] = value;
|
||||
}
|
||||
let next = {name: parts[0], children: [], attributes: attributes};
|
||||
if (path.length) {
|
||||
path[path.length - 1].children.push(next);
|
||||
} else {
|
||||
result = next;
|
||||
}
|
||||
if (!tag.endsWith('/')) {
|
||||
path.push(next);
|
||||
}
|
||||
}
|
||||
tag_begin = undefined;
|
||||
text_begin = i + 1;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function* xml_each(node, name) {
|
||||
for (let child of node.children) {
|
||||
if (child.name == name) {
|
||||
yield child;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function gpx_parse(xml) {
|
||||
let result = {segments: []};
|
||||
let tree = xml_parse(xml);
|
||||
if (tree?.name == 'gpx') {
|
||||
for (let trk of xml_each(tree, 'trk')) {
|
||||
for (let trkseg of xml_each(trk, 'trkseg')) {
|
||||
let segment = [];
|
||||
for (let trkpt of xml_each(trkseg, 'trkpt')) {
|
||||
segment.push({lat: parseFloat(trkpt.attributes.lat), lon: parseFloat(trkpt.attributes.lon)});
|
||||
}
|
||||
result.segments.push(segment);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let metadata of xml_each(tree, 'metadata')) {
|
||||
for (let link of xml_each(metadata, 'link')) {
|
||||
result.link = link.attributes.href;
|
||||
}
|
||||
for (let time of xml_each(metadata, 'time')) {
|
||||
result.time = time.value;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
21
apps/gg/handler.js
Normal file
21
apps/gg/handler.js
Normal file
@ -0,0 +1,21 @@
|
||||
import * as strava from './strava.js';
|
||||
|
||||
async function main() {
|
||||
print('handler running');
|
||||
let r = await strava.authorization_code(request.query.code);
|
||||
print('state =', request.query.state);
|
||||
print('body = ', r.body);
|
||||
if (request.query.state && r.body) {
|
||||
let shared_db = await shared_database('state');
|
||||
await shared_db.set(request.query.state, utf8Decode(r.body));
|
||||
}
|
||||
await respond({
|
||||
data: r.body,
|
||||
content_type: 'text/plain',
|
||||
headers: {
|
||||
Location: 'https://tildefriends.net/~cory/gg/',
|
||||
},
|
||||
status_code: 307,
|
||||
});
|
||||
}
|
||||
main();
|
14
apps/gg/index.html
Normal file
14
apps/gg/index.html
Normal file
@ -0,0 +1,14 @@
|
||||
<!DOCTYPE html>
|
||||
<html style="width: 100%; height: 100%; margin: 0; padding: 0">
|
||||
<head>
|
||||
<script>window.litDisableBundleWarning = true;</script>
|
||||
<script>
|
||||
let g_data = ${data};
|
||||
</script>
|
||||
<script src="script.js" type="module"></script>
|
||||
<script src="leaflet.js"></script>
|
||||
</head>
|
||||
<body style="color: #fff; display: flex; flex-flow: column; height: 100%; width: 100%; margin: 0; padding: 0">
|
||||
<gg-app style="width: 100%; height: 100%" id="ggapp"></gg-app>
|
||||
</body>
|
||||
</html>
|
661
apps/gg/leaflet.css
Normal file
661
apps/gg/leaflet.css
Normal file
@ -0,0 +1,661 @@
|
||||
/* required styles */
|
||||
|
||||
.leaflet-pane,
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-tile-container,
|
||||
.leaflet-pane > svg,
|
||||
.leaflet-pane > canvas,
|
||||
.leaflet-zoom-box,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-layer {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-container {
|
||||
overflow: hidden;
|
||||
}
|
||||
.leaflet-tile,
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
}
|
||||
/* Prevents IE11 from highlighting tiles in blue */
|
||||
.leaflet-tile::selection {
|
||||
background: transparent;
|
||||
}
|
||||
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
|
||||
.leaflet-safari .leaflet-tile {
|
||||
image-rendering: -webkit-optimize-contrast;
|
||||
}
|
||||
/* hack that prevents hw layers "stretching" when loading new tiles */
|
||||
.leaflet-safari .leaflet-tile-container {
|
||||
width: 1600px;
|
||||
height: 1600px;
|
||||
-webkit-transform-origin: 0 0;
|
||||
}
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow {
|
||||
display: block;
|
||||
}
|
||||
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
|
||||
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
|
||||
.leaflet-container .leaflet-overlay-pane svg {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
.leaflet-container .leaflet-marker-pane img,
|
||||
.leaflet-container .leaflet-shadow-pane img,
|
||||
.leaflet-container .leaflet-tile-pane img,
|
||||
.leaflet-container img.leaflet-image-layer,
|
||||
.leaflet-container .leaflet-tile {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
width: auto;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.leaflet-container img.leaflet-tile {
|
||||
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
|
||||
mix-blend-mode: plus-lighter;
|
||||
}
|
||||
|
||||
.leaflet-container.leaflet-touch-zoom {
|
||||
-ms-touch-action: pan-x pan-y;
|
||||
touch-action: pan-x pan-y;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag {
|
||||
-ms-touch-action: pinch-zoom;
|
||||
/* Fallback for FF which doesn't support pinch-zoom */
|
||||
touch-action: none;
|
||||
touch-action: pinch-zoom;
|
||||
}
|
||||
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
|
||||
-ms-touch-action: none;
|
||||
touch-action: none;
|
||||
}
|
||||
.leaflet-container {
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.leaflet-container a {
|
||||
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
|
||||
}
|
||||
.leaflet-tile {
|
||||
filter: inherit;
|
||||
visibility: hidden;
|
||||
}
|
||||
.leaflet-tile-loaded {
|
||||
visibility: inherit;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
width: 0;
|
||||
height: 0;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
z-index: 800;
|
||||
}
|
||||
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
|
||||
.leaflet-overlay-pane svg {
|
||||
-moz-user-select: none;
|
||||
}
|
||||
|
||||
.leaflet-pane { z-index: 400; }
|
||||
|
||||
.leaflet-tile-pane { z-index: 200; }
|
||||
.leaflet-overlay-pane { z-index: 400; }
|
||||
.leaflet-shadow-pane { z-index: 500; }
|
||||
.leaflet-marker-pane { z-index: 600; }
|
||||
.leaflet-tooltip-pane { z-index: 650; }
|
||||
.leaflet-popup-pane { z-index: 700; }
|
||||
|
||||
.leaflet-map-pane canvas { z-index: 100; }
|
||||
.leaflet-map-pane svg { z-index: 200; }
|
||||
|
||||
.leaflet-vml-shape {
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
}
|
||||
.lvml {
|
||||
behavior: url(#default#VML);
|
||||
display: inline-block;
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
|
||||
/* control positioning */
|
||||
|
||||
.leaflet-control {
|
||||
position: relative;
|
||||
z-index: 800;
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-top,
|
||||
.leaflet-bottom {
|
||||
position: absolute;
|
||||
z-index: 1000;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-top {
|
||||
top: 0;
|
||||
}
|
||||
.leaflet-right {
|
||||
right: 0;
|
||||
}
|
||||
.leaflet-bottom {
|
||||
bottom: 0;
|
||||
}
|
||||
.leaflet-left {
|
||||
left: 0;
|
||||
}
|
||||
.leaflet-control {
|
||||
float: left;
|
||||
clear: both;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
float: right;
|
||||
}
|
||||
.leaflet-top .leaflet-control {
|
||||
margin-top: 10px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.leaflet-left .leaflet-control {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.leaflet-right .leaflet-control {
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
|
||||
/* zoom and fade animations */
|
||||
|
||||
.leaflet-fade-anim .leaflet-popup {
|
||||
opacity: 0;
|
||||
-webkit-transition: opacity 0.2s linear;
|
||||
-moz-transition: opacity 0.2s linear;
|
||||
transition: opacity 0.2s linear;
|
||||
}
|
||||
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
|
||||
opacity: 1;
|
||||
}
|
||||
.leaflet-zoom-animated {
|
||||
-webkit-transform-origin: 0 0;
|
||||
-ms-transform-origin: 0 0;
|
||||
transform-origin: 0 0;
|
||||
}
|
||||
svg.leaflet-zoom-animated {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-animated {
|
||||
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
|
||||
}
|
||||
.leaflet-zoom-anim .leaflet-tile,
|
||||
.leaflet-pan-anim .leaflet-tile {
|
||||
-webkit-transition: none;
|
||||
-moz-transition: none;
|
||||
transition: none;
|
||||
}
|
||||
|
||||
.leaflet-zoom-anim .leaflet-zoom-hide {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
|
||||
/* cursors */
|
||||
|
||||
.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
}
|
||||
.leaflet-grab {
|
||||
cursor: -webkit-grab;
|
||||
cursor: -moz-grab;
|
||||
cursor: grab;
|
||||
}
|
||||
.leaflet-crosshair,
|
||||
.leaflet-crosshair .leaflet-interactive {
|
||||
cursor: crosshair;
|
||||
}
|
||||
.leaflet-popup-pane,
|
||||
.leaflet-control {
|
||||
cursor: auto;
|
||||
}
|
||||
.leaflet-dragging .leaflet-grab,
|
||||
.leaflet-dragging .leaflet-grab .leaflet-interactive,
|
||||
.leaflet-dragging .leaflet-marker-draggable {
|
||||
cursor: move;
|
||||
cursor: -webkit-grabbing;
|
||||
cursor: -moz-grabbing;
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
/* marker & overlays interactivity */
|
||||
.leaflet-marker-icon,
|
||||
.leaflet-marker-shadow,
|
||||
.leaflet-image-layer,
|
||||
.leaflet-pane > svg path,
|
||||
.leaflet-tile-container {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.leaflet-marker-icon.leaflet-interactive,
|
||||
.leaflet-image-layer.leaflet-interactive,
|
||||
.leaflet-pane > svg path.leaflet-interactive,
|
||||
svg.leaflet-image-layer.leaflet-interactive path {
|
||||
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* visual tweaks */
|
||||
|
||||
.leaflet-container {
|
||||
background: #ddd;
|
||||
outline-offset: 1px;
|
||||
}
|
||||
.leaflet-container a {
|
||||
color: #0078A8;
|
||||
}
|
||||
.leaflet-zoom-box {
|
||||
border: 2px dotted #38f;
|
||||
background: rgba(255,255,255,0.5);
|
||||
}
|
||||
|
||||
|
||||
/* general typography */
|
||||
.leaflet-container {
|
||||
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
|
||||
font-size: 12px;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
|
||||
/* general toolbar styles */
|
||||
|
||||
.leaflet-bar {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
|
||||
border-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #ccc;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
line-height: 26px;
|
||||
display: block;
|
||||
text-align: center;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
}
|
||||
.leaflet-bar a,
|
||||
.leaflet-control-layers-toggle {
|
||||
background-position: 50% 50%;
|
||||
background-repeat: no-repeat;
|
||||
display: block;
|
||||
}
|
||||
.leaflet-bar a:hover,
|
||||
.leaflet-bar a:focus {
|
||||
background-color: #f4f4f4;
|
||||
}
|
||||
.leaflet-bar a:first-child {
|
||||
border-top-left-radius: 4px;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
.leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 4px;
|
||||
border-bottom-right-radius: 4px;
|
||||
border-bottom: none;
|
||||
}
|
||||
.leaflet-bar a.leaflet-disabled {
|
||||
cursor: default;
|
||||
background-color: #f4f4f4;
|
||||
color: #bbb;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-bar a {
|
||||
width: 30px;
|
||||
height: 30px;
|
||||
line-height: 30px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:first-child {
|
||||
border-top-left-radius: 2px;
|
||||
border-top-right-radius: 2px;
|
||||
}
|
||||
.leaflet-touch .leaflet-bar a:last-child {
|
||||
border-bottom-left-radius: 2px;
|
||||
border-bottom-right-radius: 2px;
|
||||
}
|
||||
|
||||
/* zoom control */
|
||||
|
||||
.leaflet-control-zoom-in,
|
||||
.leaflet-control-zoom-out {
|
||||
font: bold 18px 'Lucida Console', Monaco, monospace;
|
||||
text-indent: 1px;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
|
||||
font-size: 22px;
|
||||
}
|
||||
|
||||
|
||||
/* layers control */
|
||||
|
||||
.leaflet-control-layers {
|
||||
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
|
||||
background: #fff;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers.png);
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
}
|
||||
.leaflet-retina .leaflet-control-layers-toggle {
|
||||
background-image: url(images/layers-2x.png);
|
||||
background-size: 26px 26px;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers-toggle {
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
}
|
||||
.leaflet-control-layers .leaflet-control-layers-list,
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
|
||||
display: none;
|
||||
}
|
||||
.leaflet-control-layers-expanded .leaflet-control-layers-list {
|
||||
display: block;
|
||||
position: relative;
|
||||
}
|
||||
.leaflet-control-layers-expanded {
|
||||
padding: 6px 10px 6px 6px;
|
||||
color: #333;
|
||||
background: #fff;
|
||||
}
|
||||
.leaflet-control-layers-scrollbar {
|
||||
overflow-y: scroll;
|
||||
overflow-x: hidden;
|
||||
padding-right: 5px;
|
||||
}
|
||||
.leaflet-control-layers-selector {
|
||||
margin-top: 2px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
.leaflet-control-layers label {
|
||||
display: block;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
}
|
||||
.leaflet-control-layers-separator {
|
||||
height: 0;
|
||||
border-top: 1px solid #ddd;
|
||||
margin: 5px -10px 5px -6px;
|
||||
}
|
||||
|
||||
/* Default icon URLs */
|
||||
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
|
||||
background-image: url(images/marker-icon.png);
|
||||
}
|
||||
|
||||
|
||||
/* attribution and scale controls */
|
||||
|
||||
.leaflet-container .leaflet-control-attribution {
|
||||
background: #fff;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
margin: 0;
|
||||
}
|
||||
.leaflet-control-attribution,
|
||||
.leaflet-control-scale-line {
|
||||
padding: 0 5px;
|
||||
color: #333;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.leaflet-control-attribution a {
|
||||
text-decoration: none;
|
||||
}
|
||||
.leaflet-control-attribution a:hover,
|
||||
.leaflet-control-attribution a:focus {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.leaflet-attribution-flag {
|
||||
display: inline !important;
|
||||
vertical-align: baseline !important;
|
||||
width: 1em;
|
||||
height: 0.6669em;
|
||||
}
|
||||
.leaflet-left .leaflet-control-scale {
|
||||
margin-left: 5px;
|
||||
}
|
||||
.leaflet-bottom .leaflet-control-scale {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
.leaflet-control-scale-line {
|
||||
border: 2px solid #777;
|
||||
border-top: none;
|
||||
line-height: 1.1;
|
||||
padding: 2px 5px 1px;
|
||||
white-space: nowrap;
|
||||
-moz-box-sizing: border-box;
|
||||
box-sizing: border-box;
|
||||
background: rgba(255, 255, 255, 0.8);
|
||||
text-shadow: 1px 1px #fff;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child) {
|
||||
border-top: 2px solid #777;
|
||||
border-bottom: none;
|
||||
margin-top: -2px;
|
||||
}
|
||||
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
|
||||
border-bottom: 2px solid #777;
|
||||
}
|
||||
|
||||
.leaflet-touch .leaflet-control-attribution,
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
box-shadow: none;
|
||||
}
|
||||
.leaflet-touch .leaflet-control-layers,
|
||||
.leaflet-touch .leaflet-bar {
|
||||
border: 2px solid rgba(0,0,0,0.2);
|
||||
background-clip: padding-box;
|
||||
}
|
||||
|
||||
|
||||
/* popup */
|
||||
|
||||
.leaflet-popup {
|
||||
position: absolute;
|
||||
text-align: center;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
.leaflet-popup-content-wrapper {
|
||||
padding: 1px;
|
||||
text-align: left;
|
||||
border-radius: 12px;
|
||||
}
|
||||
.leaflet-popup-content {
|
||||
margin: 13px 24px 13px 20px;
|
||||
line-height: 1.3;
|
||||
font-size: 13px;
|
||||
font-size: 1.08333em;
|
||||
min-height: 1px;
|
||||
}
|
||||
.leaflet-popup-content p {
|
||||
margin: 17px 0;
|
||||
margin: 1.3em 0;
|
||||
}
|
||||
.leaflet-popup-tip-container {
|
||||
width: 40px;
|
||||
height: 20px;
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
margin-top: -1px;
|
||||
margin-left: -20px;
|
||||
overflow: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
.leaflet-popup-tip {
|
||||
width: 17px;
|
||||
height: 17px;
|
||||
padding: 1px;
|
||||
|
||||
margin: -10px auto 0;
|
||||
pointer-events: auto;
|
||||
|
||||
-webkit-transform: rotate(45deg);
|
||||
-moz-transform: rotate(45deg);
|
||||
-ms-transform: rotate(45deg);
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
.leaflet-popup-content-wrapper,
|
||||
.leaflet-popup-tip {
|
||||
background: white;
|
||||
color: #333;
|
||||
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
right: 0;
|
||||
border: none;
|
||||
text-align: center;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
font: 16px/24px Tahoma, Verdana, sans-serif;
|
||||
color: #757575;
|
||||
text-decoration: none;
|
||||
background: transparent;
|
||||
}
|
||||
.leaflet-container a.leaflet-popup-close-button:hover,
|
||||
.leaflet-container a.leaflet-popup-close-button:focus {
|
||||
color: #585858;
|
||||
}
|
||||
.leaflet-popup-scrolled {
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper {
|
||||
-ms-zoom: 1;
|
||||
}
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
width: 24px;
|
||||
margin: 0 auto;
|
||||
|
||||
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
|
||||
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
|
||||
}
|
||||
|
||||
.leaflet-oldie .leaflet-control-zoom,
|
||||
.leaflet-oldie .leaflet-control-layers,
|
||||
.leaflet-oldie .leaflet-popup-content-wrapper,
|
||||
.leaflet-oldie .leaflet-popup-tip {
|
||||
border: 1px solid #999;
|
||||
}
|
||||
|
||||
|
||||
/* div icon */
|
||||
|
||||
.leaflet-div-icon {
|
||||
background: #fff;
|
||||
border: 1px solid #666;
|
||||
}
|
||||
|
||||
|
||||
/* Tooltip */
|
||||
/* Base styles for the element that has a tooltip */
|
||||
.leaflet-tooltip {
|
||||
position: absolute;
|
||||
padding: 6px;
|
||||
background-color: #fff;
|
||||
border: 1px solid #fff;
|
||||
border-radius: 3px;
|
||||
color: #222;
|
||||
white-space: nowrap;
|
||||
-webkit-user-select: none;
|
||||
-moz-user-select: none;
|
||||
-ms-user-select: none;
|
||||
user-select: none;
|
||||
pointer-events: none;
|
||||
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
|
||||
}
|
||||
.leaflet-tooltip.leaflet-interactive {
|
||||
cursor: pointer;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.leaflet-tooltip-top:before,
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
position: absolute;
|
||||
pointer-events: none;
|
||||
border: 6px solid transparent;
|
||||
background: transparent;
|
||||
content: "";
|
||||
}
|
||||
|
||||
/* Directions */
|
||||
|
||||
.leaflet-tooltip-bottom {
|
||||
margin-top: 6px;
|
||||
}
|
||||
.leaflet-tooltip-top {
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before,
|
||||
.leaflet-tooltip-top:before {
|
||||
left: 50%;
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-top:before {
|
||||
bottom: 0;
|
||||
margin-bottom: -12px;
|
||||
border-top-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-bottom:before {
|
||||
top: 0;
|
||||
margin-top: -12px;
|
||||
margin-left: -6px;
|
||||
border-bottom-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-left {
|
||||
margin-left: -6px;
|
||||
}
|
||||
.leaflet-tooltip-right {
|
||||
margin-left: 6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before,
|
||||
.leaflet-tooltip-right:before {
|
||||
top: 50%;
|
||||
margin-top: -6px;
|
||||
}
|
||||
.leaflet-tooltip-left:before {
|
||||
right: 0;
|
||||
margin-right: -12px;
|
||||
border-left-color: #fff;
|
||||
}
|
||||
.leaflet-tooltip-right:before {
|
||||
left: 0;
|
||||
margin-left: -12px;
|
||||
border-right-color: #fff;
|
||||
}
|
||||
|
||||
/* Printing */
|
||||
|
||||
@media print {
|
||||
/* Prevent printers from removing background-images of controls. */
|
||||
.leaflet-control {
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
}
|
6
apps/gg/leaflet.js
Normal file
6
apps/gg/leaflet.js
Normal file
File diff suppressed because one or more lines are too long
1
apps/gg/leaflet.js.map
Normal file
1
apps/gg/leaflet.js.map
Normal file
File diff suppressed because one or more lines are too long
120
apps/gg/lit-all.min.js
vendored
Normal file
120
apps/gg/lit-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
apps/gg/lit-all.min.js.map
Normal file
1
apps/gg/lit-all.min.js.map
Normal file
File diff suppressed because one or more lines are too long
158
apps/gg/polyline.js
Normal file
158
apps/gg/polyline.js
Normal file
@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Based off of [the offical Google document](https://developers.google.com/maps/documentation/utilities/polylinealgorithm)
|
||||
*
|
||||
* Some parts from [this implementation](http://facstaff.unca.edu/mcmcclur/GoogleMaps/EncodePolyline/PolylineEncoder.js)
|
||||
* by [Mark McClure](http://facstaff.unca.edu/mcmcclur/)
|
||||
*
|
||||
* @module polyline
|
||||
*/
|
||||
|
||||
var polyline = {};
|
||||
|
||||
function py2_round(value) {
|
||||
// Google's polyline algorithm uses the same rounding strategy as Python 2, which is different from JS for negative values
|
||||
return Math.floor(Math.abs(value) + 0.5) * (value >= 0 ? 1 : -1);
|
||||
}
|
||||
|
||||
function encode(current, previous, factor) {
|
||||
current = py2_round(current * factor);
|
||||
previous = py2_round(previous * factor);
|
||||
var coordinate = (current - previous) * 2;
|
||||
if (coordinate < 0) {
|
||||
coordinate = -coordinate - 1
|
||||
}
|
||||
var output = '';
|
||||
while (coordinate >= 0x20) {
|
||||
output += String.fromCharCode((0x20 | (coordinate & 0x1f)) + 63);
|
||||
coordinate /= 32;
|
||||
}
|
||||
output += String.fromCharCode((coordinate | 0) + 63);
|
||||
return output;
|
||||
}
|
||||
|
||||
/**
|
||||
* Decodes to a [latitude, longitude] coordinates array.
|
||||
*
|
||||
* This is adapted from the implementation in Project-OSRM.
|
||||
*
|
||||
* @param {String} str
|
||||
* @param {Number} precision
|
||||
* @returns {Array}
|
||||
*
|
||||
* @see https://github.com/Project-OSRM/osrm-frontend/blob/master/WebContent/routing/OSRM.RoutingGeometry.js
|
||||
*/
|
||||
polyline.decode = function(str, precision) {
|
||||
var index = 0,
|
||||
lat = 0,
|
||||
lng = 0,
|
||||
coordinates = [],
|
||||
shift = 0,
|
||||
result = 0,
|
||||
byte = null,
|
||||
latitude_change,
|
||||
longitude_change,
|
||||
factor = Math.pow(10, Number.isInteger(precision) ? precision : 5);
|
||||
|
||||
// Coordinates have variable length when encoded, so just keep
|
||||
// track of whether we've hit the end of the string. In each
|
||||
// loop iteration, a single coordinate is decoded.
|
||||
while (index < str.length) {
|
||||
|
||||
// Reset shift, result, and byte
|
||||
byte = null;
|
||||
shift = 1;
|
||||
result = 0;
|
||||
|
||||
do {
|
||||
byte = str.charCodeAt(index++) - 63;
|
||||
result += (byte & 0x1f) * shift;
|
||||
shift *= 32;
|
||||
} while (byte >= 0x20);
|
||||
|
||||
latitude_change = (result & 1) ? ((-result - 1) / 2) : (result / 2);
|
||||
|
||||
shift = 1;
|
||||
result = 0;
|
||||
|
||||
do {
|
||||
byte = str.charCodeAt(index++) - 63;
|
||||
result += (byte & 0x1f) * shift;
|
||||
shift *= 32;
|
||||
} while (byte >= 0x20);
|
||||
|
||||
longitude_change = (result & 1) ? ((-result - 1) / 2) : (result / 2);
|
||||
|
||||
lat += latitude_change;
|
||||
lng += longitude_change;
|
||||
|
||||
coordinates.push([lat / factor, lng / factor]);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
};
|
||||
|
||||
/**
|
||||
* Encodes the given [latitude, longitude] coordinates array.
|
||||
*
|
||||
* @param {Array.<Array.<Number>>} coordinates
|
||||
* @param {Number} precision
|
||||
* @returns {String}
|
||||
*/
|
||||
polyline.encode = function(coordinates, precision) {
|
||||
if (!coordinates.length) { return ''; }
|
||||
|
||||
var factor = Math.pow(10, Number.isInteger(precision) ? precision : 5),
|
||||
output = encode(coordinates[0][0], 0, factor) + encode(coordinates[0][1], 0, factor);
|
||||
|
||||
for (var i = 1; i < coordinates.length; i++) {
|
||||
var a = coordinates[i], b = coordinates[i - 1];
|
||||
output += encode(a[0], b[0], factor);
|
||||
output += encode(a[1], b[1], factor);
|
||||
}
|
||||
|
||||
return output;
|
||||
};
|
||||
|
||||
function flipped(coords) {
|
||||
var flipped = [];
|
||||
for (var i = 0; i < coords.length; i++) {
|
||||
var coord = coords[i].slice();
|
||||
flipped.push([coord[1], coord[0]]);
|
||||
}
|
||||
return flipped;
|
||||
}
|
||||
|
||||
/**
|
||||
* Encodes a GeoJSON LineString feature/geometry.
|
||||
*
|
||||
* @param {Object} geojson
|
||||
* @param {Number} precision
|
||||
* @returns {String}
|
||||
*/
|
||||
polyline.fromGeoJSON = function(geojson, precision) {
|
||||
if (geojson && geojson.type === 'Feature') {
|
||||
geojson = geojson.geometry;
|
||||
}
|
||||
if (!geojson || geojson.type !== 'LineString') {
|
||||
throw new Error('Input must be a GeoJSON LineString');
|
||||
}
|
||||
return polyline.encode(flipped(geojson.coordinates), precision);
|
||||
};
|
||||
|
||||
/**
|
||||
* Decodes to a GeoJSON LineString geometry.
|
||||
*
|
||||
* @param {String} str
|
||||
* @param {Number} precision
|
||||
* @returns {Object}
|
||||
*/
|
||||
polyline.toGeoJSON = function(str, precision) {
|
||||
var coords = polyline.decode(str, precision);
|
||||
return {
|
||||
type: 'LineString',
|
||||
coordinates: flipped(coords)
|
||||
};
|
||||
};
|
||||
|
||||
let polyline_decode = polyline.decode;
|
||||
export { polyline_decode as decode };
|
807
apps/gg/script.js
Normal file
807
apps/gg/script.js
Normal file
@ -0,0 +1,807 @@
|
||||
import {LitElement, html, unsafeHTML, css, guard, until} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import * as polyline from './polyline.js';
|
||||
import {gpx_parse} from './gpx.js';
|
||||
|
||||
const k_client_id = '28276';
|
||||
const k_redirect_url = 'https://tildefriends.net/~cory/gg/login';
|
||||
|
||||
const k_color_snow = [128, 128, 255, 255];
|
||||
const k_color_ice = [160, 160, 255, 255];
|
||||
const k_color_water = [0, 0, 255, 255];
|
||||
const k_color_dirt = [128, 129, 130, 255];
|
||||
const k_color_pavement = [32, 32, 32, 255];
|
||||
const k_color_grass = [0, 255, 0, 255];
|
||||
const k_color_default = [128, 128, 128, 255];
|
||||
|
||||
const k_store = {
|
||||
'🦞': 15,
|
||||
'🛶': 10,
|
||||
'🏠': 10,
|
||||
'⛰': 10,
|
||||
'🐠': 10,
|
||||
};
|
||||
|
||||
const k_marker_snap = {x: 5, y: 4};
|
||||
|
||||
class GgAppElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
user: {type: Object},
|
||||
strava: {type: Object},
|
||||
activities: {type: Array},
|
||||
activity: {type: Object},
|
||||
world: {type: Object},
|
||||
whoami: {type: String},
|
||||
status: {type: Object},
|
||||
tab: {type: String},
|
||||
url: {type: String},
|
||||
currency: {type: Number},
|
||||
to_build: {type: String},
|
||||
emoji_of_the_day: {type: String},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.activities = [];
|
||||
this.activity = {};
|
||||
this.loaded_activities = [];
|
||||
this.placed_emojis = [];
|
||||
this.strava = {};
|
||||
this.min_lat = Number.MAX_VALUE;
|
||||
this.min_lon = Number.MAX_VALUE;
|
||||
this.max_lat = -Number.MAX_VALUE;
|
||||
this.max_lon = -Number.MAX_VALUE;
|
||||
this.focus = undefined;
|
||||
this.status = undefined;
|
||||
this.tab = 'map';
|
||||
this.load().catch(function(e) {
|
||||
console.log('load error', e);
|
||||
});
|
||||
this.to_build = '🏠';
|
||||
}
|
||||
|
||||
async load() {
|
||||
console.log('load');
|
||||
let emojis = await (await fetch('emojis.json')).json();
|
||||
emojis = Object.values(emojis).map(x => Object.values(x)).flat();
|
||||
let today = new Date();
|
||||
let date_index = today.getYear() * 356 + today.getMonth() * 31 + today.getDate();
|
||||
this.emoji_of_the_day = emojis[(date_index * 123457) % emojis.length];
|
||||
this.user = await tfrpc.rpc.getUser();
|
||||
this.url = (await tfrpc.rpc.url()).split('?')[0];
|
||||
try {
|
||||
await this.update_credentials();
|
||||
} catch (e) {
|
||||
console.log('update_credentials failed', e);
|
||||
}
|
||||
try {
|
||||
await this.update_activities();
|
||||
} catch (e) {
|
||||
console.log('update_activities failed', e);
|
||||
}
|
||||
await this.acquire_ssb_identity();
|
||||
if (this.whoami && this.activities?.length) {
|
||||
await this.sync_activities();
|
||||
}
|
||||
await this.get_activities_from_ssb();
|
||||
}
|
||||
|
||||
/* https://gist.github.com/jcouyang/632709f30e12a7879a73e9e132c0d56b?permalink_comment_id=3591045#gistcomment-3591045 */
|
||||
async promise_all(promises, max_concurrent) {
|
||||
let index = 0;
|
||||
let results = [];
|
||||
async function exec_thread() {
|
||||
while (index < promises.length) {
|
||||
const current = index++;
|
||||
results[current] = await promises[current];
|
||||
}
|
||||
}
|
||||
const threads = [];
|
||||
for (let thread = 0; thread < max_concurrent; thread++) {
|
||||
threads.push(exec_thread());
|
||||
}
|
||||
await Promise.all(threads);
|
||||
return results;
|
||||
}
|
||||
|
||||
async get_activities_from_ssb() {
|
||||
this.status = {text: 'loading activities'};
|
||||
this.loaded_activities = [];
|
||||
let rows = await tfrpc.rpc.query(`
|
||||
SELECT messages.author, json_extract(mention.value, '$.link') AS blob_id
|
||||
FROM messages_fts('"gg-activity"')
|
||||
JOIN messages ON messages.rowid = messages_fts.rowid,
|
||||
json_each(messages.content, '$.mentions') as mention
|
||||
WHERE json_extract(messages.content, '$.type') = 'gg-activity' AND
|
||||
json_extract(mention.value, '$.name') = 'activity_data'
|
||||
ORDER BY messages.timestamp DESC
|
||||
`, []);
|
||||
this.status = {text: 'loading activity data'};
|
||||
let authors = rows.map(x => x.author);
|
||||
let blobs = await this.promise_all(rows.map(x => tfrpc.rpc.get_blob(x.blob_id)), 8);
|
||||
this.status = {text: 'processing activity data'};
|
||||
for (let [index, blob] of blobs.entries()) {
|
||||
let activity;
|
||||
try {
|
||||
activity = JSON.parse(blob);
|
||||
} catch {
|
||||
activity = gpx_parse(blob);
|
||||
}
|
||||
if (activity) {
|
||||
activity.author = authors[index];
|
||||
this.loaded_activities.push(activity);
|
||||
}
|
||||
}
|
||||
this.status = {text: 'calculating balance'};
|
||||
rows = await tfrpc.rpc.query(`
|
||||
SELECT count(*) AS currency FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-activity'
|
||||
`, [this.whoami]);
|
||||
let currency = rows[0].currency;
|
||||
rows = await tfrpc.rpc.query(`
|
||||
SELECT SUM(json_extract(content, '$.cost')) AS cost FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-place'
|
||||
`, [this.whoami]);
|
||||
let spent = rows[0].cost;
|
||||
this.currency = currency - spent;
|
||||
this.status = {text: 'getting placed emojis'};
|
||||
rows = await tfrpc.rpc.query(`
|
||||
SELECT messages.content
|
||||
FROM messages_fts('"gg-place"')
|
||||
JOIN messages ON messages.rowid = messages_fts.rowid
|
||||
WHERE json_extract(messages.content, '$.type') = 'gg-place'
|
||||
ORDER BY messages.timestamp
|
||||
`);
|
||||
for (let row of rows) {
|
||||
console.log(row.content);
|
||||
let content = JSON.parse(row.content);
|
||||
this.placed_emojis.push({
|
||||
position: content.position,
|
||||
emoji: content.emoji,
|
||||
});
|
||||
}
|
||||
console.log(this.placed_emojis);
|
||||
this.status = undefined;
|
||||
this.update_map();
|
||||
}
|
||||
|
||||
async sync_activities() {
|
||||
let ids = this.activities.map(x => `https://www.strava.com/activities/${x.id}`);
|
||||
let missing = await tfrpc.rpc.query(`
|
||||
WITH my_activities AS (
|
||||
SELECT json_extract(mention.value, '$.link') AS url
|
||||
FROM messages, json_each(messages.content, '$.mentions') AS mention
|
||||
WHERE
|
||||
author = ? AND
|
||||
json_extract(messages.content, '$.type') = 'gg-activity' AND
|
||||
json_extract(mention.value, '$.name') = 'activity_url')
|
||||
SELECT from_strava.value FROM json_each(?) AS from_strava
|
||||
LEFT OUTER JOIN my_activities ON from_strava.value = my_activities.url
|
||||
WHERE my_activities.url IS NULL
|
||||
`, [this.whoami, JSON.stringify(ids)]);
|
||||
console.log('missing = ', missing);
|
||||
for (let [index, row] of missing.entries()) {
|
||||
this.status = {text: 'syncing from strava', value: index, max: missing.length};
|
||||
let url = row.value;
|
||||
let id = url.match(/.*\/(\d+)/)[1];
|
||||
let response = await fetch(`https://www.strava.com/api/v3/activities/${id}`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.strava.access_token}`,
|
||||
},
|
||||
});
|
||||
let activity = await response.json();
|
||||
let blob_id = await tfrpc.rpc.store_blob(JSON.stringify(activity));
|
||||
let message = {
|
||||
type: 'gg-activity',
|
||||
mentions: [
|
||||
{
|
||||
link: url,
|
||||
name: 'activity_url',
|
||||
},
|
||||
{
|
||||
link: blob_id,
|
||||
name: 'activity_data',
|
||||
}
|
||||
],
|
||||
};
|
||||
await tfrpc.rpc.appendMessage(this.whoami, message);
|
||||
}
|
||||
this.status = undefined;
|
||||
}
|
||||
|
||||
async acquire_ssb_identity() {
|
||||
let user = await tfrpc.rpc.getUser();
|
||||
if (!user?.credentials?.session?.name) {
|
||||
return;
|
||||
}
|
||||
let ids = await tfrpc.rpc.getIdentities();
|
||||
let players = ids.length ? (await tfrpc.rpc.query(`
|
||||
SELECT author FROM messages JOIN json_each(?) ON messages.author = json_each.value
|
||||
WHERE
|
||||
json_extract(messages.content, '$.type') = 'gg-player' AND
|
||||
json_extract(messages.content, '$.active')
|
||||
ORDER BY timestamp DESC limit 1
|
||||
`, [JSON.stringify(ids)])).map(row => row.author) : [];
|
||||
if (!players.length) {
|
||||
this.whoami = await tfrpc.rpc.createIdentity();
|
||||
if (this.whoami) {
|
||||
await tfrpc.rpc.appendMessage(this.whoami, {
|
||||
type: 'gg-player',
|
||||
active: true,
|
||||
});
|
||||
}
|
||||
} else {
|
||||
players.sort();
|
||||
this.whoami = players[0];
|
||||
}
|
||||
}
|
||||
|
||||
async update_credentials() {
|
||||
let name = this.user?.credentials?.session?.name;
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
let shared = await tfrpc.rpc.sharedDatabaseGet(name);
|
||||
if (shared) {
|
||||
await tfrpc.rpc.databaseSet('strava', shared);
|
||||
await tfrpc.rpc.sharedDatabaseRemove(name);
|
||||
}
|
||||
this.strava = JSON.parse(await tfrpc.rpc.databaseGet('strava') || '{}');
|
||||
if (new Date().valueOf() / 1000 > this.strava.expires_at) {
|
||||
console.log('this looks expired', new Date().valueOf() / 1000, '>', this.strava.expires_at);
|
||||
let x = await tfrpc.rpc.refresh_token(this.strava);
|
||||
if (x) {
|
||||
this.strava = x;
|
||||
await tfrpc.rpc.databaseSet('strava', JSON.stringify(x));
|
||||
} else {
|
||||
this.strava = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async update_activities() {
|
||||
if (this?.strava?.access_token) {
|
||||
let response = await fetch('https://www.strava.com/api/v3/athlete/activities', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${this.strava.access_token}`,
|
||||
},
|
||||
});
|
||||
this.activities = await response.json();
|
||||
this.activities.sort((a, b) => (a.id - b.id));
|
||||
}
|
||||
}
|
||||
|
||||
color_to_emoji(color) {
|
||||
const k_map = [
|
||||
[k_color_snow, '⬜'],
|
||||
[k_color_ice, '🟦'],
|
||||
[k_color_water, '🟦'],
|
||||
[k_color_dirt, '🟫'],
|
||||
[k_color_pavement, '⬛'],
|
||||
[k_color_grass, '🟩'],
|
||||
[k_color_default, '🟧'],
|
||||
];
|
||||
for (let m of k_map) {
|
||||
if (m[0][0] == color[0] &&
|
||||
m[0][1] == color[1] &&
|
||||
m[0][2] == color[2] &&
|
||||
m[0][3] == color[3]) {
|
||||
return m[1];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
activity_bounds(activity) {
|
||||
let min_lat = Number.MAX_VALUE;
|
||||
let min_lon = Number.MAX_VALUE;
|
||||
let max_lat = -Number.MAX_VALUE;
|
||||
let max_lon = -Number.MAX_VALUE;
|
||||
if (activity?.map?.polyline) {
|
||||
for (let pt of polyline.decode(activity.map.polyline)) {
|
||||
min_lat = Math.min(min_lat, pt[0]);
|
||||
min_lon = Math.min(min_lon, pt[1]);
|
||||
max_lat = Math.max(max_lat, pt[0]);
|
||||
max_lon = Math.max(max_lon, pt[1]);
|
||||
}
|
||||
}
|
||||
if (activity?.segments) {
|
||||
for (let segment of activity.segments) {
|
||||
for (let pt of segment) {
|
||||
min_lat = Math.min(min_lat, pt.lat);
|
||||
min_lon = Math.min(min_lon, pt.lon);
|
||||
max_lat = Math.max(max_lat, pt.lat);
|
||||
max_lon = Math.max(max_lon, pt.lon);
|
||||
}
|
||||
}
|
||||
}
|
||||
return {
|
||||
min: {
|
||||
lat: min_lat,
|
||||
lng: min_lon,
|
||||
},
|
||||
max: {
|
||||
lat: max_lat,
|
||||
lng: max_lon,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
on_click(event) {
|
||||
let popup = L.popup()
|
||||
.setLatLng(event.latlng)
|
||||
.setContent(`
|
||||
<div><a target="_top" href="https://www.google.com/maps/search/?api=1&query=${event.latlng.lat},${event.latlng.lng}">${event.latlng.lat}, ${event.latlng.lng}</a></div>
|
||||
`)
|
||||
.openOn(this.leaflet);
|
||||
}
|
||||
|
||||
async build() {
|
||||
if (this.popup) {
|
||||
this.popup.remove();
|
||||
}
|
||||
if (!this.marker) {
|
||||
return;
|
||||
}
|
||||
let latlng = this.marker.getLatLng();
|
||||
|
||||
let cost = k_store[this.to_build];
|
||||
if (cost > this.currency) {
|
||||
alert('Insufficient funds.');
|
||||
return;
|
||||
}
|
||||
let message = {
|
||||
type: 'gg-place',
|
||||
position: {lat: latlng.lat, lng: latlng.lng},
|
||||
emoji: this.to_build,
|
||||
cost: cost,
|
||||
};
|
||||
let id = await tfrpc.rpc.appendMessage(this.whoami, message);
|
||||
this.marker.remove();
|
||||
this.placed_emojis.push({
|
||||
position: {lat: latlng.lat, lng: latlng.lng},
|
||||
emoji: this.to_build,
|
||||
});
|
||||
this.currency -= cost;
|
||||
return this.update_map();
|
||||
}
|
||||
|
||||
on_marker_click(event) {
|
||||
this.popup = L.popup()
|
||||
.setLatLng(event.latlng)
|
||||
.setContent(`
|
||||
${this.to_build} (-${k_store[this.to_build]}) <input type="button" value="Build" onclick="document.getElementById('ggapp').build()"></input>
|
||||
`)
|
||||
.openOn(this.leaflet);
|
||||
}
|
||||
|
||||
snap_to_grid(latlng, fudge, zoom) {
|
||||
let position = this.leaflet.options.crs.latLngToPoint(latlng, zoom ?? this.leaflet.getZoom());
|
||||
position.x = Math.round(position.x / 16) * 16 + (fudge?.x ?? 0);
|
||||
position.y = Math.round(position.y / 16) * 16 + (fudge?.y ?? 0);
|
||||
position = this.leaflet.options.crs.pointToLatLng(position, zoom ?? this.leaflet.getZoom());
|
||||
return position;
|
||||
}
|
||||
|
||||
on_marker_move(event) {
|
||||
if (!this.no_snap && this.marker) {
|
||||
this.no_snap = true;
|
||||
this.marker.setLatLng(this.snap_to_grid(this.marker.getLatLng(), k_marker_snap));
|
||||
this.no_snap = false;
|
||||
}
|
||||
}
|
||||
|
||||
on_zoom(event) {
|
||||
if (this.marker) {
|
||||
this.marker.setLatLng(this.snap_to_grid(this.marker.getLatLng(), k_marker_snap));
|
||||
}
|
||||
}
|
||||
|
||||
on_mouse_down(event) {
|
||||
if (this.marker) {
|
||||
this.marker.remove();
|
||||
this.marker = undefined;
|
||||
}
|
||||
|
||||
if (this.to_build) {
|
||||
this.marker = L.marker(this.snap_to_grid(event.latlng, k_marker_snap), {icon: L.divIcon({className: 'build-icon'}), draggable: true}).addTo(this.leaflet);
|
||||
this.marker.on({click: this.on_marker_click.bind(this)});
|
||||
this.marker.on({drag: this.on_marker_move.bind(this)});
|
||||
}
|
||||
}
|
||||
|
||||
async update_map() {
|
||||
let map = this.shadowRoot.getElementById('map');
|
||||
if (!map || !this.loaded_activities.length) {
|
||||
this.leaflet = undefined;
|
||||
this.grid_layer = undefined;
|
||||
return;
|
||||
}
|
||||
if (!this.leaflet) {
|
||||
this.leaflet = L.map(map, {attributionControl: false, maxZoom: 16, bounceAtZoomLimits: false});
|
||||
this.leaflet.on({contextmenu: this.on_click.bind(this)});
|
||||
this.leaflet.on({click: this.on_mouse_down.bind(this)});
|
||||
this.leaflet.on({zoom: this.on_zoom.bind(this)});
|
||||
}
|
||||
let self = this;
|
||||
let grid_layer = L.GridLayer.extend({
|
||||
createTile: function(coords) {
|
||||
var tile = L.DomUtil.create('canvas', 'leaflet-tile');
|
||||
var size = this.getTileSize();
|
||||
tile.width = size.x;
|
||||
tile.height = size.y;
|
||||
var context = tile.getContext('2d');
|
||||
context.font = '10pt sans';
|
||||
let bounds = this._tileCoordsToBounds(coords);
|
||||
let degrees = 360.0 / (2 ** coords.z);
|
||||
let ul = bounds.getNorthWest();
|
||||
let lr = bounds.getSouthEast();
|
||||
|
||||
let mini = document.createElement('canvas');
|
||||
mini.width = Math.floor(size.x / 16.0);
|
||||
mini.height = Math.floor(size.y / 16.0);
|
||||
let mini_context = mini.getContext('2d');
|
||||
let image_data = context.getImageData(0, 0, mini.width, mini.height);
|
||||
for (let activity of self.loaded_activities) {
|
||||
self.draw_activity_to_tile(image_data, mini.width, mini.height, ul, lr, activity);
|
||||
}
|
||||
context.textAlign = 'left';
|
||||
context.textBaseline = 'bottom';
|
||||
for (let x = 0; x < mini.width; x++) {
|
||||
for (let y = 0; y < mini.height; y++) {
|
||||
let start = (y * mini.width + x) * 4;
|
||||
let pixel = self.color_to_emoji(image_data.data.slice(start, start + 4));
|
||||
if (pixel) {
|
||||
//context.fillRect(x * size.x / mini.width, y * size.y / mini.height, size.x / mini.width, size.y / mini.height);
|
||||
context.fillText(pixel, x * size.x / mini.width, y * size.y / mini.height + mini.height);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (let placed of self.placed_emojis) {
|
||||
let position = self.leaflet.options.crs.latLngToPoint(self.snap_to_grid(placed.position, undefined, coords.z), coords.z);
|
||||
let tile_x = Math.floor(position.x / size.x);
|
||||
let tile_y = Math.floor(position.y / size.y);
|
||||
position.x = position.x - tile_x * size.x;
|
||||
position.y = position.y - tile_y * size.y;
|
||||
if (tile_x == coords.x && tile_y == coords.y) {
|
||||
//context.fillRect(position.x, position.y, size.x / mini.width, size.y / mini.height);
|
||||
context.fillText(placed.emoji, position.x, position.y + mini.height);
|
||||
}
|
||||
}
|
||||
return tile;
|
||||
}
|
||||
});
|
||||
if (this.grid_layer) {
|
||||
this.grid_layer.redraw();
|
||||
} else {
|
||||
this.grid_layer = new grid_layer();
|
||||
this.grid_layer.addTo(this.leaflet);
|
||||
}
|
||||
for (let activity of this.loaded_activities) {
|
||||
let bounds = this.activity_bounds(activity);
|
||||
this.min_lat = Math.min(this.min_lat, bounds.min.lat);
|
||||
this.min_lon = Math.min(this.min_lon, bounds.min.lng);
|
||||
this.max_lat = Math.max(this.max_lat, bounds.max.lat);
|
||||
this.max_lon = Math.max(this.max_lon, bounds.max.lng);
|
||||
}
|
||||
if (this.focus) {
|
||||
this.leaflet.fitBounds([
|
||||
this.focus.min,
|
||||
this.focus.max,
|
||||
]);
|
||||
this.focus = undefined;
|
||||
} else {
|
||||
this.leaflet.fitBounds([
|
||||
[this.min_lat, this.min_lon],
|
||||
[this.max_lat, this.max_lon],
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
activity_to_color(activity) {
|
||||
let color = [0, 0, 0, 255];
|
||||
switch (activity.sport_type) {
|
||||
/* Implies snow. */
|
||||
case 'AlpineSki':
|
||||
case 'BackcountrySki':
|
||||
case 'NordicSki':
|
||||
case 'Snowshoe':
|
||||
case 'Snowboard':
|
||||
color = k_color_snow;
|
||||
break;
|
||||
|
||||
/* Implies ice. */
|
||||
case 'IceSkate':
|
||||
case 'InlineSkate':
|
||||
color = k_color_ice;
|
||||
break;
|
||||
|
||||
/* Implies water. */
|
||||
case 'Canoeing':
|
||||
case 'Kayaking':
|
||||
case 'Kitesurf':
|
||||
case 'Rowing':
|
||||
case 'Sail':
|
||||
case 'StandUpPaddling':
|
||||
case 'Surfing':
|
||||
case 'Swim':
|
||||
case 'Windsurf':
|
||||
color = k_color_water;
|
||||
break;
|
||||
|
||||
/* Implies dirt. */
|
||||
case 'EMountainBikeRide':
|
||||
case 'Hike':
|
||||
case 'MountainBikeRide':
|
||||
case 'RockClimbing':
|
||||
case 'TrailRun':
|
||||
color = k_color_dirt;
|
||||
break;
|
||||
|
||||
/* Implies pavement. */
|
||||
case 'EBikeRide':
|
||||
case 'GravelRide':
|
||||
case 'Handcycle':
|
||||
case 'Ride':
|
||||
case 'RollerSki':
|
||||
case 'Run':
|
||||
case 'Skateboard':
|
||||
case 'Badminton':
|
||||
case 'Tennis':
|
||||
case 'Velomobile':
|
||||
case 'Walk':
|
||||
case 'Wheelchair':
|
||||
color = k_color_pavement;
|
||||
break;
|
||||
|
||||
/* Grass, maybe? */
|
||||
case 'Golf':
|
||||
case 'Soccer':
|
||||
case 'Squash':
|
||||
color = k_color_grass;
|
||||
break;
|
||||
|
||||
// Crossfit,
|
||||
// Elliptical
|
||||
// HighIntensityIntervalTraining
|
||||
// Pickleball
|
||||
// Pilates
|
||||
// Racquetball
|
||||
// StairStepper
|
||||
// TableTennis,
|
||||
// VirtualRide
|
||||
// VirtualRow
|
||||
// VirtualRun
|
||||
// WeightTraining
|
||||
// Workout
|
||||
// Yoga
|
||||
default:
|
||||
color = k_color_default;
|
||||
}
|
||||
return color;
|
||||
}
|
||||
|
||||
line(image_data, x0, y0, x1, y1, value) {
|
||||
/* <3 https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm */
|
||||
let dx = Math.abs(x1 - x0);
|
||||
let sx = x0 < x1 ? 1 : -1;
|
||||
let dy = -Math.abs(y1 - y0);
|
||||
let sy = y0 < y1 ? 1 : -1;
|
||||
let error = dx + dy;
|
||||
while (true) {
|
||||
if (x0 >= 0 && y0 >= 0 && x0 < image_data.width && y0 < image_data.height) {
|
||||
let base = (y0 * image_data.width + x0) * 4;
|
||||
image_data.data[base + 0] = value[0];
|
||||
image_data.data[base + 1] = value[1];
|
||||
image_data.data[base + 2] = value[2];
|
||||
image_data.data[base + 3] = value[3];
|
||||
}
|
||||
|
||||
if (x0 == x1 && y0 == y1) {
|
||||
break;
|
||||
}
|
||||
let e2 = 2 * error;
|
||||
if (e2 >= dy) {
|
||||
if (x0 == x1) {
|
||||
break;
|
||||
}
|
||||
error += dy;
|
||||
x0 = Math.round(x0 + sx);
|
||||
}
|
||||
if (e2 <= dx) {
|
||||
if (y0 == y1) {
|
||||
break;
|
||||
}
|
||||
error += dx;
|
||||
y0 = Math.round(y0 + sy);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
draw_activity_to_tile(image_data, width, height, ul, lr, activity) {
|
||||
let color = this.activity_to_color(activity);
|
||||
if (activity?.map?.polyline) {
|
||||
let last;
|
||||
for (let pt of polyline.decode(activity.map.polyline)) {
|
||||
let px = [
|
||||
Math.floor(width * (pt[1] - ul.lng) / (lr.lng - ul.lng)),
|
||||
Math.floor(height * (pt[0] - ul.lat) / (lr.lat - ul.lat)),
|
||||
];
|
||||
if (last) {
|
||||
this.line(image_data, last[0], last[1], px[0], px[1], color);
|
||||
}
|
||||
last = px;
|
||||
}
|
||||
}
|
||||
if (activity?.segments) {
|
||||
for (let segment of activity.segments) {
|
||||
let last;
|
||||
for (let pt of segment) {
|
||||
let px = [
|
||||
Math.floor(width * (pt.lon - ul.lng) / (lr.lng - ul.lng)),
|
||||
Math.floor(height * (pt.lat - ul.lat) / (lr.lat - ul.lat)),
|
||||
];
|
||||
if (last) {
|
||||
this.line(image_data, last[0], last[1], px[0], px[1], color);
|
||||
}
|
||||
last = px;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async on_upload(event) {
|
||||
try {
|
||||
let file = event.srcElement.files[0];
|
||||
let xml = await file.text();
|
||||
let gpx = gpx_parse(xml);
|
||||
let blob_id = await tfrpc.rpc.store_blob(xml);
|
||||
console.log('blob_id = ', blob_id);
|
||||
console.log(gpx);
|
||||
let message = {
|
||||
type: 'gg-activity',
|
||||
mentions: [
|
||||
{
|
||||
link: `https://${gpx.link}/activity/${gpx.time}`,
|
||||
name: 'activity_url',
|
||||
},
|
||||
{
|
||||
link: blob_id,
|
||||
name: 'activity_data',
|
||||
}
|
||||
],
|
||||
};
|
||||
console.log('id =', this.whoami, 'message = ', message);
|
||||
let id = await tfrpc.rpc.appendMessage(this.whoami, message);
|
||||
console.log('appended message', id);
|
||||
alert('Activity uploaded.');
|
||||
await this.get_activities_from_ssb();
|
||||
} catch (e) {
|
||||
alert(`Error: ${JSON.stringify(e, null, 2)}`);
|
||||
}
|
||||
}
|
||||
|
||||
upload() {
|
||||
let input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.onchange = (event) => this.on_upload(event);
|
||||
input.click();
|
||||
}
|
||||
|
||||
updated() {
|
||||
this.update_map();
|
||||
}
|
||||
|
||||
focus_map(activity) {
|
||||
let bounds = this.activity_bounds(activity);
|
||||
if (bounds.min.lat < bounds.max.lat &&
|
||||
bounds.min.lng < bounds.max.lng) {
|
||||
this.tab = 'map';
|
||||
this.focus = bounds;
|
||||
}
|
||||
}
|
||||
|
||||
render_news() {
|
||||
return html`
|
||||
<ul>
|
||||
${this.loaded_activities.map(x => html`
|
||||
<li style="cursor: pointer" @click=${() => this.focus_map(x)}>${x.author} ${x.name ?? x.time}</li>
|
||||
`)}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
render_store_item(item) {
|
||||
let [emoji, cost] = item;
|
||||
return html`
|
||||
<div>
|
||||
<input type="button" value="${emoji}" @click=${() => this.to_build = emoji}></input> ${cost} ${emoji == this.to_build ? '<-- Will be built next' : undefined}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
render_store() {
|
||||
let store = Object.assign({}, k_store);
|
||||
store[this.emoji_of_the_day] = 5;
|
||||
return html`
|
||||
<h2>Store</h2>
|
||||
<div><b>Your balance:</b> ${this.currency}</div>
|
||||
${Object.entries(store).map(this.render_store_item.bind(this))}
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
let header;
|
||||
if (!this.user?.credentials?.session?.name) {
|
||||
header = html`<div style="flex: 1 0">Please <a target="_top" href="/login?return=${this.url}">login</a> to Tilde Friends, first.</div>`;
|
||||
} else if (!this.strava?.access_token) {
|
||||
let strava_url = `https://www.strava.com/oauth/authorize?client_id=${k_client_id}&redirect_uri=${k_redirect_url}&response_type=code&approval_prompt=auto&scope=activity%3Aread&state=${g_data.state}`;
|
||||
header = html`
|
||||
<div style="flex: 1 0; display: flex; flex-direction: row; align-items: center; gap: 1em; width: 100%">
|
||||
<div style="flex: 1 1">Please <a target="_top" href=${strava_url}>login</a> to Strava.</div>
|
||||
<span style="font-size: xx-small; flex: 1 1; word-break: break-all">${this.whoami}</span>
|
||||
<input type="button" value="📁" @click=${this.upload}></input>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
header = html`
|
||||
<div>
|
||||
<div style="flex: 1 0; display: flex; flex-direction: row; align-items: center; gap: 1em; width: 100%">
|
||||
<h1>Welcome, ${this.user.credentials.session.name}</h1>
|
||||
<span style="font-size: xx-small; flex: 1 1; word-break: break-all">${this.whoami}</span>
|
||||
<input type="button" value="📁" @click=${this.upload}></input>
|
||||
</div>
|
||||
<h3 ?hidden=${!this.status?.text}>${this.status?.text} <progress ?hidden=${!this.status?.max} value=${this.status?.value} max=${this.status?.max}>${this.status?.value}</progress></h3>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
let navigation = html`
|
||||
<style>
|
||||
#navigation input[type="button"] {
|
||||
min-width: 3em;
|
||||
min-height: 3em;
|
||||
flex: 1 0;
|
||||
font-size: large;
|
||||
}
|
||||
</style>
|
||||
<div id="navigation" style="display: flex; flex-direction: row">
|
||||
<input type="button" id="button_map" @click=${() => this.tab = 'map'} value="🗺️Map"></input>
|
||||
<input type="button" id="button_news" @click=${() => this.tab = 'news'} value="🏃News"></input>
|
||||
<input type="button" id="button_friends" @click=${() => this.tab = 'friends'} value="👫Friends"></input>
|
||||
<input type="button" id="button_store" @click=${() => this.tab = 'store'} value="🏗️Store"></input>
|
||||
</div>
|
||||
`;
|
||||
|
||||
let content;
|
||||
switch (this.tab) {
|
||||
case 'map':
|
||||
content = html`<div id="map" style="width: 100%; height: 100%"></div>`;
|
||||
break;
|
||||
case 'news':
|
||||
content = this.render_news();
|
||||
break;
|
||||
case 'friends':
|
||||
content = html`<div>Friends</div>`;
|
||||
break;
|
||||
case 'store':
|
||||
content = this.render_store();
|
||||
break;
|
||||
}
|
||||
|
||||
return html`
|
||||
<style>
|
||||
.build-icon::before {
|
||||
content: '📍';
|
||||
border: 2px solid red;
|
||||
}
|
||||
</style>
|
||||
<link rel="stylesheet" href="leaflet.css"/>
|
||||
<div style="width: 100%; height: 100%; display: flex; flex-direction: column">
|
||||
${header}
|
||||
<div style="flex: 1 0; overflow: scroll">${content}</div>
|
||||
${navigation}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('gg-app', GgAppElement);
|
20
apps/gg/strava.js
Normal file
20
apps/gg/strava.js
Normal file
@ -0,0 +1,20 @@
|
||||
const k_client_id = '28276';
|
||||
const k_client_secret = '3123f1f5afe132d9731111066d1d17bdb22ef27e';
|
||||
const k_access_token = 'f753e77764c26252bd2d80e7c5cc17ace51a8864';
|
||||
const k_refresh_token = 'f58d8e1b5a3ec3bf96e681589d5014f9a294f5a4';
|
||||
const k_redirect_url = 'https://tildefriends.net/~cory/gg/login';
|
||||
|
||||
export async function refresh_token(token) {
|
||||
let r = await fetch('https://www.strava.com/api/v3/oauth/token', {
|
||||
method: 'POST',
|
||||
body: `client_id=${k_client_id}&client_secret=${k_client_secret}&refresh_token=${token.refresh_token}&grant_type=refresh_token`,
|
||||
});
|
||||
return r?.body ? JSON.parse(utf8Decode(r.body)) : undefined;
|
||||
}
|
||||
|
||||
export async function authorization_code(code) {
|
||||
return await fetch('https://www.strava.com/api/v3/oauth/token', {
|
||||
method: 'POST',
|
||||
body: `client_id=${k_client_id}&client_secret=${k_client_secret}&code=${code}&grant_type=authorization_code`,
|
||||
});
|
||||
}
|
4
apps/issues.json
Normal file
4
apps/issues.json
Normal file
@ -0,0 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "🦟"
|
||||
}
|
105
apps/issues/app.js
Normal file
105
apps/issues/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();
|
91
apps/issues/commonmark-linkify.js
Normal file
91
apps/issues/commonmark-linkify.js
Normal file
@ -0,0 +1,91 @@
|
||||
function textNode(text) {
|
||||
const node = new commonmark.Node("text", undefined);
|
||||
node.literal = text;
|
||||
return node;
|
||||
}
|
||||
|
||||
function linkNode(text, url) {
|
||||
const urlNode = new commonmark.Node("link", undefined);
|
||||
urlNode.destination = url;
|
||||
urlNode.appendChild(textNode(text));
|
||||
|
||||
return urlNode;
|
||||
}
|
||||
|
||||
function splitMatches(text, regexp) {
|
||||
// Regexp must be sticky.
|
||||
regexp = new RegExp(regexp, "gm");
|
||||
|
||||
let i = 0;
|
||||
const result = [];
|
||||
|
||||
let match = regexp.exec(text);
|
||||
while (match) {
|
||||
const matchText = match[0];
|
||||
|
||||
if (match.index > i) {
|
||||
result.push([text.substring(i, match.index), false]);
|
||||
}
|
||||
|
||||
result.push([matchText, true]);
|
||||
i = match.index + matchText.length;
|
||||
|
||||
match = regexp.exec(text);
|
||||
}
|
||||
|
||||
if (i < text.length) {
|
||||
result.push([text.substring(i, text.length), false]);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
const urlRegexp = new RegExp("https?://[^ ]+[^ .,]");
|
||||
|
||||
function splitURLs(textNodes) {
|
||||
const text = textNodes.map(n => n.literal).join("");
|
||||
const parts = splitMatches(text, urlRegexp);
|
||||
|
||||
return parts.map(part => {
|
||||
if (part[1]) {
|
||||
return linkNode(part[0], part[0]);
|
||||
} else {
|
||||
return textNode(part[0]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
export function transform(parsed) {
|
||||
const walker = parsed.walker();
|
||||
let event;
|
||||
|
||||
let nodes = [];
|
||||
while ((event = walker.next())) {
|
||||
const node = event.node;
|
||||
if (event.entering && node.type === "text") {
|
||||
nodes.push(node);
|
||||
} else {
|
||||
if (nodes.length > 0) {
|
||||
splitURLs(nodes)
|
||||
.reverse()
|
||||
.forEach(newNode => {
|
||||
nodes[0].insertAfter(newNode);
|
||||
});
|
||||
|
||||
nodes.forEach(n => n.unlink());
|
||||
nodes = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nodes.length > 0) {
|
||||
splitURLs(nodes)
|
||||
.reverse()
|
||||
.forEach(newNode => {
|
||||
nodes[0].insertAfter(newNode);
|
||||
});
|
||||
nodes.forEach(n => n.unlink());
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
1
apps/issues/commonmark.min.js
vendored
Normal file
1
apps/issues/commonmark.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
14
apps/issues/index.html
Normal file
14
apps/issues/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-issues-app/>
|
||||
<script>window.litDisableBundleWarning = true;</script>
|
||||
<script src="commonmark.min.js"></script>
|
||||
<script src="commonmark-linkify.js" type="module"></script>
|
||||
<script src="script.js" type="module"></script>
|
||||
</body>
|
||||
</html>
|
120
apps/issues/lit-all.min.js
vendored
Normal file
120
apps/issues/lit-all.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
1
apps/issues/lit-all.min.js.map
Normal file
1
apps/issues/lit-all.min.js.map
Normal file
File diff suppressed because one or more lines are too long
260
apps/issues/script.js
Normal file
260
apps/issues/script.js
Normal file
@ -0,0 +1,260 @@
|
||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import * as tfutils from './tf-utils.js';
|
||||
|
||||
const k_project = '%Hr+4xEVtjplidSKBlRWi4Aw/0Tfw7B+1OR9BzlDKmOI=.sha256';
|
||||
|
||||
class TfIdPickerElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
ids: {type: Array},
|
||||
selected: {type: String},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
this.selected = await tfrpc.rpc.localStorageGet('whoami');
|
||||
this.ids = (await tfrpc.rpc.getIdentities()) || [];
|
||||
}
|
||||
|
||||
changed(event) {
|
||||
this.selected = event.srcElement.value;
|
||||
tfrpc.rpc.localStorageSet('whoami', this.selected);
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.ids) {
|
||||
return html`
|
||||
<select @change=${this.changed} style="max-width: 100%">
|
||||
${(this.ids).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)}
|
||||
</select>
|
||||
`;
|
||||
} else {
|
||||
return html`<div>Loading...</div>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
customElements.define('tf-id-picker', TfIdPickerElement);
|
||||
|
||||
class TfComposeElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
value: {type: String},
|
||||
};
|
||||
}
|
||||
|
||||
input() {
|
||||
let input = this.renderRoot.getElementById('input');
|
||||
let preview = this.renderRoot.getElementById('preview');
|
||||
if (input && preview) {
|
||||
preview.innerHTML = tfutils.markdown(input.value);
|
||||
}
|
||||
}
|
||||
|
||||
submit() {
|
||||
this.dispatchEvent(new CustomEvent('tf-submit', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
value: this.renderRoot.getElementById('input').value,
|
||||
},
|
||||
}));
|
||||
this.renderRoot.getElementById('input').value = '';
|
||||
this.input();
|
||||
}
|
||||
|
||||
render() {
|
||||
return html`
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<textarea id="input" @input=${this.input} style="flex: 1 1">${this.value}</textarea>
|
||||
<div id="preview" style="flex: 1 1"></div>
|
||||
</div>
|
||||
<input type="submit" value="Submit" @click=${this.submit}></input>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('tf-compose', TfComposeElement);
|
||||
|
||||
class TfIssuesAppElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
issues: {type: Array},
|
||||
selected: {type: Object},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.issues = [];
|
||||
this.load();
|
||||
}
|
||||
|
||||
async load() {
|
||||
let issues = {};
|
||||
let messages = await tfrpc.rpc.query(`
|
||||
WITH issues AS (SELECT messages.* FROM messages_refs JOIN messages ON
|
||||
messages.id = messages_refs.message
|
||||
WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'),
|
||||
edits AS (SELECT messages.* FROM issues JOIN messages_refs ON
|
||||
issues.id = messages_refs.ref JOIN messages ON
|
||||
messages.id = messages_refs.message
|
||||
WHERE json_extract(messages.content, '$.type') IN ('issue-edit', 'post'))
|
||||
SELECT * FROM issues
|
||||
UNION
|
||||
SELECT * FROM edits ORDER BY timestamp
|
||||
`, [k_project]);
|
||||
for (let message of messages) {
|
||||
let content = JSON.parse(message.content);
|
||||
switch (content.type) {
|
||||
case 'issue':
|
||||
issues[message.id] = {
|
||||
id: message.id,
|
||||
author: message.author,
|
||||
text: content.text,
|
||||
updates: [],
|
||||
created: message.timestamp,
|
||||
open: true,
|
||||
};
|
||||
break;
|
||||
case 'issue-edit':
|
||||
case 'post':
|
||||
for (let issue of (content.issues || [])) {
|
||||
if (issues[issue.link]) {
|
||||
if (issue.open !== undefined) {
|
||||
issues[issue.link].open = issue.open;
|
||||
message.open = issue.open;
|
||||
}
|
||||
issues[issue.link].updates.push(message);
|
||||
issues[issue.link].updated = message.timestamp;
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
this.issues = Object.values(issues).sort((x, y) => y.created - x.created);
|
||||
if (this.selected) {
|
||||
for (let issue of this.issues) {
|
||||
if (issue.id == this.selected.id) {
|
||||
this.selected = issue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render_issue_table_row(issue) {
|
||||
return html`
|
||||
<tr>
|
||||
<td>${issue.open ? 'open' : 'closed'}</td>
|
||||
<td style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis">${issue.author}</td>
|
||||
<td style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer" @click=${() => this.selected = issue}>
|
||||
${issue.text.split('\n')?.[0]}
|
||||
</td>
|
||||
<td>${new Date(issue.updated ?? issue.created).toLocaleDateString()}</td>
|
||||
</tr>
|
||||
`;
|
||||
}
|
||||
|
||||
render_update(update) {
|
||||
let content = JSON.parse(update.content);
|
||||
let message;
|
||||
if (content.text) {
|
||||
message = unsafeHTML(tfutils.markdown(content.text));
|
||||
}
|
||||
return html`
|
||||
<div style="border-left: 2px solid #fff; padding-left: 8px">
|
||||
<div>${new Date(update.timestamp).toLocaleString()}</div>
|
||||
<div>${update.author}</div>
|
||||
<div>${message}</div>
|
||||
<div>${update.open !== undefined ? (update.open ? 'issue opened' : 'issue closed') : undefined}</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
async set_open(id, open) {
|
||||
if (confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`)) {
|
||||
let whoami = this.shadowRoot.getElementById('picker').selected;
|
||||
await tfrpc.rpc.appendMessage(whoami, {
|
||||
type: 'issue-edit',
|
||||
issues: [
|
||||
{
|
||||
link: id,
|
||||
open: open,
|
||||
},
|
||||
],
|
||||
});
|
||||
await this.load();
|
||||
}
|
||||
}
|
||||
|
||||
async create_issue(event) {
|
||||
let whoami = this.shadowRoot.getElementById('picker').selected;
|
||||
await tfrpc.rpc.appendMessage(whoami, {
|
||||
type: 'issue',
|
||||
project: k_project,
|
||||
text: event.detail.value,
|
||||
});
|
||||
await this.load();
|
||||
}
|
||||
|
||||
async reply_to_issue(event) {
|
||||
let whoami = this.shadowRoot.getElementById('picker').selected;
|
||||
await tfrpc.rpc.appendMessage(whoami, {
|
||||
type: 'post',
|
||||
text: event.detail.value,
|
||||
root: this.selected.id,
|
||||
branch: this.selected.updates.length ? this.selected.updates[this.selected.updates.length - 1].id : this.selected.id,
|
||||
issues: [
|
||||
{
|
||||
link: this.selected.id,
|
||||
},
|
||||
],
|
||||
});
|
||||
await this.load();
|
||||
}
|
||||
|
||||
render() {
|
||||
let header = html`
|
||||
<h1>Tilde Friends Issues</h1>
|
||||
<tf-id-picker id="picker"></tf-id-picker>
|
||||
`;
|
||||
if (this.selected) {
|
||||
return html`
|
||||
${header}
|
||||
<div>
|
||||
<input type="button" value="Back" @click=${() => this.selected = undefined}></input>
|
||||
${this.selected.open ?
|
||||
html`<input type="button" value="Close Issue" @click=${() => this.set_open(this.selected.id, false)}></input>` :
|
||||
html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>`}
|
||||
</div>
|
||||
<div>${new Date(this.selected.created).toLocaleString()}</div>
|
||||
<div>${this.selected.author}</div>
|
||||
<div>${this.selected.id}</div>
|
||||
<div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div>
|
||||
${this.selected.updates.map(x => this.render_update(x))}
|
||||
<tf-compose @tf-submit=${this.reply_to_issue}></tf-compose>
|
||||
`;
|
||||
} else {
|
||||
return html`
|
||||
${header}
|
||||
<h2>New Issue</h2>
|
||||
<tf-compose @tf-submit=${this.create_issue}></tf-compose>
|
||||
<table>
|
||||
<tr>
|
||||
<th>Status</th>
|
||||
<th>Author</th>
|
||||
<th>Title</th>
|
||||
<th>Date</th>
|
||||
</tr>
|
||||
${this.issues.map(x => this.render_issue_table_row(x))}
|
||||
</table>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-issues-app', TfIssuesAppElement);
|
91
apps/issues/tf-utils.js
Normal file
91
apps/issues/tf-utils.js
Normal file
@ -0,0 +1,91 @@
|
||||
import * as linkify from './commonmark-linkify.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);
|
||||
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/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
120
apps/sneaker/lit-all.min.js
vendored
Normal file
120
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
232
apps/sneaker/script.js
Normal file
232
apps/sneaker/script.js
Normal file
@ -0,0 +1,232 @@
|
||||
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 messages_refs.ref AS id
|
||||
FROM messages
|
||||
JOIN messages_refs ON messages.id = messages_refs.message
|
||||
WHERE messages.author = ? AND messages_refs.ref LIKE '&%.sha256'`,
|
||||
[id]);
|
||||
let blobs_done = 0;
|
||||
for (let row of blobs) {
|
||||
this.progress = {name: 'blobs', value: blobs_done, max: blobs.length};
|
||||
let blob;
|
||||
try {
|
||||
blob = await tfrpc.rpc.get_blob(row.id);
|
||||
} catch (e) {
|
||||
console.log(`Failed to get ${row.id}: ${e.message}`);
|
||||
}
|
||||
if (blob) {
|
||||
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);
|
@ -1,3 +1,5 @@
|
||||
{
|
||||
"type": "tildefriends-app"
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "🐌",
|
||||
"previous": "&HrGonbL2IZBtoP3zh1glMUO16+PZ4/t0KFTpkNMYoPI=.sha256"
|
||||
}
|
@ -18,6 +18,12 @@ tfrpc.register(async function databaseSet(key, value) {
|
||||
tfrpc.register(async function createIdentity() {
|
||||
return ssb.createIdentity();
|
||||
});
|
||||
tfrpc.register(async function getServerIdentity() {
|
||||
return ssb.getServerIdentity();
|
||||
});
|
||||
tfrpc.register(async function setServerFollowingMe(id, following) {
|
||||
return ssb.setServerFollowingMe(id, following);
|
||||
});
|
||||
tfrpc.register(async function getIdentities() {
|
||||
return ssb.getIdentities();
|
||||
});
|
||||
@ -79,9 +85,18 @@ tfrpc.register(async function store_blob(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);
|
||||
});
|
||||
tfrpc.register(async function encrypt(id, recipients, content) {
|
||||
return await ssb.privateMessageEncrypt(id, recipients, content);
|
||||
});
|
||||
ssb.addEventListener('broadcasts', async function() {
|
||||
await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts());
|
||||
});
|
||||
|
@ -54,21 +54,27 @@ export function picker(callback, anchor) {
|
||||
}
|
||||
}
|
||||
|
||||
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 search = input.value.toLowerCase();
|
||||
let any_at_all = false;
|
||||
Object.entries(json).forEach(function(row) {
|
||||
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 row[1]) {
|
||||
for (let entry of Object.entries(row[1])) {
|
||||
if (search &&
|
||||
search.length &&
|
||||
entry.name.indexOf(search) == -1) {
|
||||
entry[0].toLowerCase().indexOf(search) == -1) {
|
||||
continue;
|
||||
}
|
||||
let emoji = document.createElement('span');
|
||||
@ -76,12 +82,9 @@ export function picker(callback, anchor) {
|
||||
emoji.style.display = 'inline-block';
|
||||
emoji.style.overflow = 'hidden';
|
||||
emoji.style.cursor = 'pointer';
|
||||
emoji.onclick = function() {
|
||||
callback(entry);
|
||||
cleanup();
|
||||
}
|
||||
emoji.title = entry.name;
|
||||
emoji.appendChild(document.createTextNode(entry.emoji));
|
||||
emoji.onclick = chosen;
|
||||
emoji.title = entry[0];
|
||||
emoji.appendChild(document.createTextNode(entry[1]));
|
||||
list.appendChild(emoji);
|
||||
any = true;
|
||||
any_at_all = true;
|
||||
@ -89,7 +92,7 @@ export function picker(callback, anchor) {
|
||||
if (!any) {
|
||||
list.removeChild(header);
|
||||
}
|
||||
});
|
||||
}
|
||||
if (!any_at_all) {
|
||||
list.appendChild(document.createTextNode('No matches found.'));
|
||||
}
|
||||
|
15116
apps/ssb/emojis.json
15116
apps/ssb/emojis.json
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
|
1
apps/ssb/filesaver.min.js.map
Normal file
1
apps/ssb/filesaver.min.js.map
Normal file
File diff suppressed because one or more lines are too long
@ -13,6 +13,7 @@
|
||||
<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>
|
||||
|
56
apps/ssb/lit-all.min.js
vendored
56
apps/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
@ -8,6 +8,10 @@ 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_tab_query from './tf-tab-query.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,10 @@ class TfElement extends LitElement {
|
||||
this.tab = 'search';
|
||||
} else if (this.hash === '#connections') {
|
||||
this.tab = 'connections';
|
||||
} else if (this.hash === '#mentions') {
|
||||
this.tab = 'mentions';
|
||||
} else if (this.hash.startsWith('#sql=')) {
|
||||
this.tab = 'query';
|
||||
} else {
|
||||
this.tab = 'news';
|
||||
}
|
||||
@ -79,7 +85,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]);
|
||||
@ -135,9 +141,9 @@ class TfElement extends LitElement {
|
||||
cache.last_row_id = max_row_id;
|
||||
let store = JSON.stringify(cache);
|
||||
/* 2023-02-20: Exceeding message size. */
|
||||
if (store.length < 512 * 1024) {
|
||||
//if (store.length < 512 * 1024) {
|
||||
await tfrpc.rpc.databaseSet('following', store);
|
||||
}
|
||||
//}
|
||||
return [result, cache.following];
|
||||
}
|
||||
|
||||
@ -241,22 +247,47 @@ class TfElement extends LitElement {
|
||||
if (confirm("Are you sure you want to create a new identity?")) {
|
||||
await tfrpc.rpc.createIdentity();
|
||||
this.ids = (await tfrpc.rpc.getIdentities()) || [];
|
||||
if (this.ids && !this.whoami) {
|
||||
this.whoami = this.ids[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
render_id_picker() {
|
||||
return html`
|
||||
<tf-id-picker id="picker" selected=${this.whoami} .ids=${this.ids} @change=${this._handle_whoami_changed}></tf-id-picker>
|
||||
<button @click=${this.create_identity}>Create Identity</button>
|
||||
<button @click=${this.create_identity} id="create_identity">Create Identity</button>
|
||||
`;
|
||||
}
|
||||
|
||||
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;
|
||||
@ -267,35 +298,37 @@ class TfElement extends LitElement {
|
||||
let users = this.users;
|
||||
if (this.tab === 'news') {
|
||||
return html`
|
||||
<tf-tab-news .following=${this.following} whoami=${this.whoami} .users=${this.users} hash=${this.hash} .unread=${this.unread} @refresh=${() => this.unread = []}></tf-tab-news>
|
||||
<tf-tab-news id="tf-tab-news" .following=${this.following} whoami=${this.whoami} .users=${this.users} hash=${this.hash} .unread=${this.unread} @refresh=${() => this.unread = []}></tf-tab-news>
|
||||
`;
|
||||
} else if (this.tab === 'connections') {
|
||||
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>
|
||||
`;
|
||||
} else if (this.tab === 'query') {
|
||||
return html`
|
||||
<tf-tab-query .following=${this.following} whoami=${this.whoami} .users=${this.users} query=${this.hash?.startsWith('#sql=') ? decodeURIComponent(this.hash.substring(5)) : null}></tf-tab-query>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
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');
|
||||
} else if (tab === 'query') {
|
||||
await tfrpc.rpc.setHash('#sql=');
|
||||
}
|
||||
}
|
||||
|
||||
@ -303,7 +336,6 @@ class TfElement extends LitElement {
|
||||
let self = this;
|
||||
|
||||
if (!this.loading && this.whoami && this.loaded !== this.whoami) {
|
||||
console.log(`starting loading ${this.whoami} ${this.loaded}`);
|
||||
this.loading = true;
|
||||
this.load().finally(function() {
|
||||
self.loading = false;
|
||||
@ -314,7 +346,9 @@ 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>
|
||||
<input type="button" class="tab" value="Query" ?disabled=${self.tab == 'query'} @click=${() => self.set_tab('query')}></input>
|
||||
</div>
|
||||
`;
|
||||
let contents =
|
||||
@ -326,10 +360,10 @@ 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}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-app', TfElement);
|
||||
customElements.define('tf-app', TfElement);
|
||||
|
@ -13,7 +13,7 @@ class TfComposeElement extends LitElement {
|
||||
branch: {type: String},
|
||||
apps: {type: Object},
|
||||
drafts: {type: Object},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
@ -56,7 +56,7 @@ class TfComposeElement extends LitElement {
|
||||
if (!draft.mentions[link]) {
|
||||
draft.mentions[link] = {
|
||||
link: link,
|
||||
}
|
||||
};
|
||||
}
|
||||
draft.mentions[link].name = name.startsWith('@') ? name.substring(1) : name;
|
||||
updated = true;
|
||||
@ -111,7 +111,7 @@ class TfComposeElement extends LitElement {
|
||||
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.'));
|
||||
};
|
||||
@ -176,7 +176,7 @@ class TfComposeElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
submit() {
|
||||
async submit() {
|
||||
let self = this;
|
||||
let draft = this.get_draft();
|
||||
let edit = this.renderRoot.getElementById('edit');
|
||||
@ -195,14 +195,25 @@ class TfComposeElement extends LitElement {
|
||||
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) {
|
||||
if (draft.encrypt_to) {
|
||||
let to = new Set(draft.encrypt_to);
|
||||
to.add(this.whoami);
|
||||
to = [...to];
|
||||
message.recps = to;
|
||||
console.log('message is now', message);
|
||||
message = await tfrpc.rpc.encrypt(this.whoami, to, JSON.stringify(message));
|
||||
console.log('encrypted as', message);
|
||||
}
|
||||
try {
|
||||
await tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
|
||||
edit.value = '';
|
||||
self.change();
|
||||
self.notify(undefined);
|
||||
self.requestUpdate();
|
||||
});
|
||||
} catch (error) {
|
||||
alert(error.message);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
discard() {
|
||||
@ -226,12 +237,47 @@ class TfComposeElement extends LitElement {
|
||||
input.click();
|
||||
}
|
||||
|
||||
async autocomplete(text, callback) {
|
||||
this.last_autocomplete = text;
|
||||
let results = [];
|
||||
try {
|
||||
let rows = await tfrpc.rpc.query(`
|
||||
SELECT messages.content FROM messages_fts(?)
|
||||
JOIN messages ON messages.rowid = messages_fts.rowid
|
||||
WHERE messages.content LIKE ?
|
||||
ORDER BY timestamp DESC LIMIT 10
|
||||
`, ['"' + text.replace('"', '""') + '"', `%%`]);
|
||||
for (let row of rows) {
|
||||
for (let match of row.content.matchAll(/!\[([^\]]*)\]\((&.*?)\)/g)) {
|
||||
if (match[1].toLowerCase().indexOf(text.toLowerCase()) != -1) {
|
||||
results.push({key: match[1], value: match[2]});
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (this.last_autocomplete === text) {
|
||||
callback(results);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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})`;
|
||||
},
|
||||
collection: [
|
||||
{
|
||||
values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
|
||||
selectTemplate: function(item) {
|
||||
return `[@${item.original.key}](${item.original.value})`;
|
||||
},
|
||||
},
|
||||
{
|
||||
trigger: '&',
|
||||
values: this.autocomplete,
|
||||
selectTemplate: function(item) {
|
||||
return ``;
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
tribute.attach(this.renderRoot.getElementById('edit'));
|
||||
}
|
||||
@ -244,6 +290,16 @@ class TfComposeElement extends LitElement {
|
||||
preview.innerHTML = this.process_text(edit.value);
|
||||
this.last_updated_text = edit.value;
|
||||
}
|
||||
let encrypt = this.renderRoot.getElementById('encrypt_to');
|
||||
if (encrypt) {
|
||||
let tribute = new Tribute({
|
||||
values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
|
||||
selectTemplate: function(item) {
|
||||
return item.original.value;
|
||||
},
|
||||
});
|
||||
tribute.attach(encrypt);
|
||||
}
|
||||
}
|
||||
|
||||
remove_mention(id) {
|
||||
@ -256,9 +312,18 @@ class TfComposeElement extends LitElement {
|
||||
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 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>`;
|
||||
}
|
||||
|
||||
@ -283,11 +348,11 @@ class TfComposeElement extends LitElement {
|
||||
};
|
||||
}
|
||||
}
|
||||
let draft = this.get_draft();
|
||||
let draft = self.get_draft();
|
||||
draft.mentions = Object.assign(draft.mentions || {}, mentions);
|
||||
this.requestUpdate();
|
||||
this.notify(draft);
|
||||
this.apps = null;
|
||||
self.requestUpdate();
|
||||
self.notify(draft);
|
||||
self.apps = null;
|
||||
}
|
||||
|
||||
if (this.apps) {
|
||||
@ -304,13 +369,14 @@ class TfComposeElement extends LitElement {
|
||||
}
|
||||
|
||||
render_attach_app_button() {
|
||||
let self = this;
|
||||
async function attach_app() {
|
||||
this.apps = await tfrpc.rpc.apps();
|
||||
self.apps = await tfrpc.rpc.apps();
|
||||
}
|
||||
if (!this.apps) {
|
||||
return html`<input type="button" value="Attach App" @click=${attach_app}></input>`
|
||||
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>`
|
||||
return html`<input type="button" value="Discard App" @click=${() => this.apps = null}></input>`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -344,6 +410,45 @@ class TfComposeElement extends LitElement {
|
||||
return this.drafts[this.branch || ''] || {};
|
||||
}
|
||||
|
||||
update_encrypt(event) {
|
||||
let input = event.srcElement;
|
||||
let matches = input.value.match(/@.*?\.ed25519/g);
|
||||
if (matches) {
|
||||
let draft = this.get_draft();
|
||||
let to = [...new Set(matches.concat(draft.encrypt_to))];
|
||||
this.set_encrypt(to);
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
render_encrypt() {
|
||||
let draft = this.get_draft();
|
||||
if (draft.encrypt_to === undefined) {
|
||||
return;
|
||||
}
|
||||
return html`
|
||||
<div style="display: flex; flex-direction: row; width: 100%">
|
||||
<label for="encrypt_to">🔐 To:</label>
|
||||
<input type="text" id="encrypt_to" style="display: flex; flex: 1 1" @input=${this.update_encrypt}></input>
|
||||
<input type="button" value="🚮" @click=${() => this.set_encrypt(undefined)}></input>
|
||||
</div>
|
||||
<ul>
|
||||
${draft.encrypt_to.map(x => html`
|
||||
<li>
|
||||
<tf-user id=${x} .users=${this.users}></tf-user>
|
||||
<input type="button" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter(id => id != x))}></input>
|
||||
</li>`)}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
set_encrypt(encrypt) {
|
||||
let draft = this.get_draft();
|
||||
draft.encrypt_to = encrypt;
|
||||
this.notify(draft);
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
let draft = self.get_draft();
|
||||
@ -351,7 +456,11 @@ class TfComposeElement extends LitElement {
|
||||
draft.content_warning !== undefined ?
|
||||
html`<div id="content_warning_preview" class="content_warning">${draft.content_warning}</div>` :
|
||||
undefined;
|
||||
let encrypt = draft.encrypt_to !== undefined ?
|
||||
undefined :
|
||||
html`<input type="button" value="🔐" @click=${() => this.set_encrypt([])}></input>`;
|
||||
let result = html`
|
||||
${this.render_encrypt()}
|
||||
<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%">
|
||||
@ -362,13 +471,14 @@ class TfComposeElement extends LitElement {
|
||||
${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" id="submit" value="Submit" @click=${this.submit}></input>
|
||||
<input type="button" value="Attach" @click=${this.attach}></input>
|
||||
${this.render_attach_app_button()}
|
||||
${encrypt}
|
||||
<input type="button" value="Discard" @click=${this.discard}></input>
|
||||
`;
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-compose', TfComposeElement);
|
||||
customElements.define('tf-compose', TfComposeElement);
|
||||
|
@ -9,12 +9,11 @@ class TfIdentityPickerElement extends LitElement {
|
||||
return {
|
||||
ids: {type: Array},
|
||||
selected: {type: String},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.ids = [];
|
||||
}
|
||||
|
||||
@ -34,4 +33,4 @@ class TfIdentityPickerElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-id-picker', TfIdentityPickerElement);
|
||||
customElements.define('tf-id-picker', TfIdentityPickerElement);
|
||||
|
@ -11,10 +11,10 @@ class TfMessageElement extends LitElement {
|
||||
message: {type: Object},
|
||||
users: {type: Object},
|
||||
drafts: {type: Object},
|
||||
raw: {type: Boolean},
|
||||
format: {type: String},
|
||||
blog_data: {type: String},
|
||||
expanded: {type: Object},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
@ -26,12 +26,14 @@ class TfMessageElement extends LitElement {
|
||||
this.message = {};
|
||||
this.users = {};
|
||||
this.drafts = {};
|
||||
this.raw = false;
|
||||
this.format = 'message';
|
||||
this.expanded = {};
|
||||
}
|
||||
|
||||
show_reply() {
|
||||
let event = new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.message?.id, draft: ''}});
|
||||
let event = new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.message?.id, draft: {
|
||||
encrypt_to: this.message?.decrypted?.recps,
|
||||
}}});
|
||||
this.dispatchEvent(event);
|
||||
}
|
||||
|
||||
@ -69,12 +71,12 @@ class TfMessageElement extends LitElement {
|
||||
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>`
|
||||
};
|
||||
return html`<div style="white-space: pre-wrap">${JSON.stringify(raw, null, 2)}</div>`;
|
||||
}
|
||||
|
||||
vote(emoji) {
|
||||
let reaction = emoji.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(
|
||||
@ -127,6 +129,13 @@ class TfMessageElement extends LitElement {
|
||||
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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -148,7 +157,7 @@ class TfMessageElement extends LitElement {
|
||||
} else if (mention.link?.startsWith('&') &&
|
||||
mention.name?.startsWith('video:')) {
|
||||
return html`
|
||||
<video controls style="max-height: 240px">
|
||||
<video controls style="max-height: 240px; max-width: 128px">
|
||||
<source src=${'/' + mention.link + '/view'}></source>
|
||||
</video>
|
||||
`;
|
||||
@ -168,10 +177,7 @@ class TfMessageElement extends LitElement {
|
||||
|
||||
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);
|
||||
mentions = mentions.filter(x => this.message?.content?.text?.indexOf(x.link) === -1);
|
||||
if (mentions.length) {
|
||||
let self = this;
|
||||
return html`
|
||||
@ -214,24 +220,68 @@ class TfMessageElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
render_channels() {
|
||||
let content = this.message?.content;
|
||||
if (this?.messsage?.decrypted?.type == 'post') {
|
||||
content = this.message.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>`);
|
||||
}
|
||||
|
||||
render() {
|
||||
let content = this.message?.content;
|
||||
if (this.message?.decrypted?.type == 'post') {
|
||||
content = this.message.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>`;
|
||||
let raw_button;
|
||||
switch (this.format) {
|
||||
case 'raw':
|
||||
if (content?.type == 'post' || content?.type == 'blog') {
|
||||
raw_button = html`<input type="button" value="Markdown" @click=${() => self.format = 'md'}></input>`;
|
||||
} else {
|
||||
raw_button = html`<input type="button" value="Message" @click=${() => self.format = 'message'}></input>`;
|
||||
}
|
||||
break;
|
||||
case 'md':
|
||||
raw_button = html`<input type="button" value="Message" @click=${() => self.format = 'message'}></input>`;
|
||||
break;
|
||||
default:
|
||||
raw_button = html`<input type="button" value="Raw" @click=${() => self.format = 'raw'}></input>`;
|
||||
break;
|
||||
}
|
||||
function small_frame(inner) {
|
||||
let body;
|
||||
return html`
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block">
|
||||
<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.format == 'raw' ? self.render_raw() : inner}
|
||||
${self.render_votes()}
|
||||
</div>
|
||||
`
|
||||
`;
|
||||
}
|
||||
if (this.message.placeholder) {
|
||||
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)
|
||||
@ -258,7 +308,7 @@ class TfMessageElement extends LitElement {
|
||||
<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>` :
|
||||
@ -270,8 +320,9 @@ class TfMessageElement extends LitElement {
|
||||
${description}
|
||||
`);
|
||||
} else if (content.type == 'contact') {
|
||||
return small_frame(html`
|
||||
return html`
|
||||
<div>
|
||||
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
|
||||
is
|
||||
${
|
||||
content.blocking === true ? 'blocking' :
|
||||
@ -282,7 +333,7 @@ class TfMessageElement extends LitElement {
|
||||
}
|
||||
<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
|
||||
@ -296,14 +347,24 @@ class TfMessageElement extends LitElement {
|
||||
<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 body;
|
||||
switch (this.format) {
|
||||
case 'raw':
|
||||
body = this.render_raw();
|
||||
break;
|
||||
case 'md':
|
||||
body = html`<code style="white-space: pre-wrap; overflow-wrap: anywhere">${content.text}</code>`;
|
||||
break;
|
||||
case 'message':
|
||||
body = unsafeHTML(tfutils.markdown(content.text));
|
||||
break;
|
||||
}
|
||||
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()}
|
||||
`;
|
||||
@ -316,6 +377,8 @@ class TfMessageElement extends LitElement {
|
||||
` :
|
||||
content_warning :
|
||||
content_html;
|
||||
let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined;
|
||||
let style_background = this.message?.decrypted ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)';
|
||||
return html`
|
||||
<style>
|
||||
code {
|
||||
@ -331,9 +394,10 @@ class TfMessageElement extends LitElement {
|
||||
display: block;
|
||||
}
|
||||
</style>
|
||||
<div style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px">
|
||||
<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>
|
||||
@ -347,6 +411,40 @@ class TfMessageElement extends LitElement {
|
||||
${this.render_children()}
|
||||
</div>
|
||||
`;
|
||||
} else if (content.type === 'issue') {
|
||||
let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined;
|
||||
let style_background = this.message?.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>
|
||||
${content.text}
|
||||
${this.render_votes()}
|
||||
<div>
|
||||
<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) {
|
||||
@ -356,9 +454,16 @@ class TfMessageElement extends LitElement {
|
||||
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`
|
||||
let body;
|
||||
switch (this.format) {
|
||||
case 'raw':
|
||||
body = this.render_raw();
|
||||
break;
|
||||
case 'md':
|
||||
body = content.summary;
|
||||
break;
|
||||
case 'message':
|
||||
body = html`
|
||||
<div
|
||||
style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer"
|
||||
@click=${x => self.toggle_expanded(':blog')}>
|
||||
@ -370,6 +475,8 @@ class TfMessageElement extends LitElement {
|
||||
</div>
|
||||
${payload}
|
||||
`;
|
||||
break;
|
||||
}
|
||||
return html`
|
||||
<style>
|
||||
code {
|
||||
@ -418,7 +525,11 @@ class TfMessageElement extends LitElement {
|
||||
</div>
|
||||
`);
|
||||
} else if (typeof(this.message.content) == 'string') {
|
||||
return small_frame(html`<span>🔒</span>`);
|
||||
if (this.message?.decrypted) {
|
||||
return small_frame(html`<span>🔓</span><pre>${JSON.stringify(this.decrypted, null, 2)}</pre>`);
|
||||
} else {
|
||||
return small_frame(html`<span>🔒</span>`);
|
||||
}
|
||||
} else {
|
||||
return small_frame(html`<div><b>type</b>: ${content.type}</div>`);
|
||||
}
|
||||
|
@ -11,7 +11,7 @@ class TfNewsElement extends LitElement {
|
||||
following: {type: Array},
|
||||
drafts: {type: Object},
|
||||
expanded: {type: Object},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
@ -145,9 +145,29 @@ class TfNewsElement extends LitElement {
|
||||
return recursive_sort(roots, true);
|
||||
}
|
||||
|
||||
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} .drafts=${this.drafts} .expanded=${this.expanded} collapsed=true></tf-message>`)}
|
||||
|
@ -11,7 +11,8 @@ class TfProfileElement extends LitElement {
|
||||
id: {type: String},
|
||||
users: {type: Object},
|
||||
size: {type: Number},
|
||||
}
|
||||
server_follows_me: {type: Boolean},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
@ -24,6 +25,23 @@ class TfProfileElement extends LitElement {
|
||||
this.id = null;
|
||||
this.users = {};
|
||||
this.size = 0;
|
||||
this.server_follows_me = undefined;
|
||||
}
|
||||
|
||||
async initial_load() {
|
||||
this.server_follows_me = undefined;
|
||||
let server_id = await tfrpc.rpc.getServerIdentity();
|
||||
let followed = await tfrpc.rpc.query(`
|
||||
SELECT json_extract(content, '$.following') AS following FROM messages
|
||||
WHERE author = ? AND
|
||||
json_extract(content, '$.type') = 'contact' AND
|
||||
json_extract(content, '$.contact') = ? ORDER BY sequence DESC LIMIT 1
|
||||
`, [server_id, this.whoami]);
|
||||
let is_followed = false;
|
||||
for (let row of followed) {
|
||||
is_followed = row.following != 0;
|
||||
}
|
||||
this.server_follows_me = is_followed;
|
||||
}
|
||||
|
||||
modify(change) {
|
||||
@ -33,7 +51,7 @@ class TfProfileElement extends LitElement {
|
||||
contact: this.id,
|
||||
}, change)).catch(function(error) {
|
||||
alert(error?.message);
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
follow() {
|
||||
@ -103,7 +121,23 @@ class TfProfileElement extends LitElement {
|
||||
input.click();
|
||||
}
|
||||
|
||||
async server_follow_me(follow) {
|
||||
try {
|
||||
await tfrpc.rpc.setServerFollowingMe(this.whoami, follow);
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
try {
|
||||
await this.initial_load();
|
||||
} catch (e) {
|
||||
console.log(e);
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.id == this.whoami && this.editing && this.server_follows_me === undefined) {
|
||||
this.initial_load();
|
||||
}
|
||||
let self = this;
|
||||
let profile = this.users[this.id] || {};
|
||||
tfrpc.rpc.query(
|
||||
@ -116,9 +150,16 @@ class TfProfileElement extends LitElement {
|
||||
let block;
|
||||
if (this.id === this.whoami) {
|
||||
if (this.editing) {
|
||||
let server_follow;
|
||||
if (this.server_follows_me === true) {
|
||||
server_follow = html`<input type="button" value="Server, Stop Following Me" @click=${() => this.server_follow_me(false)}></input>`;
|
||||
} else if (this.server_follows_me === false) {
|
||||
server_follow = html`<input type="button" value="Server, Follow Me" @click=${() => this.server_follow_me(true)}></input>`;
|
||||
}
|
||||
edit = html`
|
||||
<input type="button" value="Save Profile" @click=${this.save_edits}></input>
|
||||
<input type="button" value="Discard" @click=${this.discard_edits}></input>
|
||||
${server_follow}
|
||||
`;
|
||||
} else {
|
||||
edit = html`<input type="button" value="Edit Profile" @click=${this.edit}></input>`;
|
||||
@ -139,23 +180,27 @@ class TfProfileElement extends LitElement {
|
||||
html`<input type="button" value="Block" @click=${this.block}></input>`;
|
||||
}
|
||||
let edit_profile = this.editing ? html`
|
||||
<div style="flex: 1 0 50%">
|
||||
<div style="flex: 1 0 50%; display: flex; flex-direction: column">
|
||||
<div>
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" value=${this.editing.name} @input=${event => this.editing = Object.assign({}, this.editing, {name: event.srcElement.value})}></input>
|
||||
</div>
|
||||
<div><label for="description">Description:</label></div>
|
||||
<textarea style="flex: 1 0" id="description" @input=${event => this.editing = Object.assign({}, this.editing, {description: event.srcElement.value})}>${this.editing.description}</textarea>
|
||||
<div>
|
||||
<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>
|
||||
<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>
|
||||
<div>
|
||||
<input type="button" value="Attach Image" @click=${this.attach_image}></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;
|
||||
image = this.editing?.image ?? image;
|
||||
let description = this.editing?.description ?? profile.description;
|
||||
return html`<div style="border: 2px solid black; background-color: rgba(255, 255, 255, 0.2); padding: 16px">
|
||||
<tf-user id=${this.id} .users=${this.users}></tf-user> (${tfutils.human_readable_size(this.size)})
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<div style="display: flex; flex-direction: row; gap: 1em">
|
||||
${edit_profile}
|
||||
<div style="flex: 1 0 50%">
|
||||
<div><img src=${'/' + image + '/view'} style="width: 256px; height: auto"></img></div>
|
||||
|
@ -36,4 +36,19 @@ img {
|
||||
padding: 8px;
|
||||
margin: 4px;
|
||||
}
|
||||
|
||||
div.img_caption {
|
||||
color: #888;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
div.img_caption::after {
|
||||
content: ' ±';
|
||||
}
|
||||
|
||||
blockquote {
|
||||
border-left: 4px solid #fff;
|
||||
margin-left: 0px;
|
||||
padding-left: 8px;
|
||||
}
|
||||
`;
|
@ -9,7 +9,7 @@ class TfTabConnectionsElement extends LitElement {
|
||||
connections: {type: Array},
|
||||
stored_connections: {type: Array},
|
||||
users: {type: Object},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@ -42,11 +42,8 @@ class TfTabConnectionsElement extends LitElement {
|
||||
let self = this;
|
||||
let peers = this.broadcasts.filter(x => x.tunnel?.id == connection);
|
||||
if (peers.length) {
|
||||
return html`
|
||||
<ul>
|
||||
${peers.map(x => html`${self.render_room_peer(x)}`)}
|
||||
</ul>
|
||||
`;
|
||||
let connections = this.connections.map(x => x.id);
|
||||
return html`${peers.filter(x => connections.indexOf(x.pubkey) == -1).map(x => html`${self.render_room_peer(x)}`)}`;
|
||||
}
|
||||
}
|
||||
|
||||
@ -59,7 +56,7 @@ class TfTabConnectionsElement extends LitElement {
|
||||
return html`
|
||||
<li>
|
||||
<input type="button" @click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)} value="Connect"></input>
|
||||
<tf-user id=${connection.pubkey} .users=${this.users}></tf-user>
|
||||
<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> 📡
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
@ -71,7 +68,7 @@ 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) {
|
||||
@ -79,6 +76,18 @@ class TfTabConnectionsElement extends LitElement {
|
||||
this.stored_connections = (await tfrpc.rpc.getStoredConnections()) || [];
|
||||
}
|
||||
|
||||
render_connection(connection) {
|
||||
return html`
|
||||
<input type="button" @click=${() => tfrpc.rpc.closeConnection(connection.id)} value="Close"></input>
|
||||
<tf-user id=${connection.id} .users=${this.users}></tf-user>
|
||||
${connection.tunnel !== undefined ? '🚇' : html`(${connection.host}:${connection.port})`}
|
||||
<ul>
|
||||
${this.connections.filter(x => x.tunnel === this.connections.indexOf(connection)).map(x => html`<li>${this.render_connection(x)}</li>`)}
|
||||
${this.render_room_peers(connection.id)}
|
||||
</ul>
|
||||
`;
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
return html`
|
||||
@ -93,12 +102,8 @@ class TfTabConnectionsElement extends LitElement {
|
||||
</ul>
|
||||
<h2>Connections</h2>
|
||||
<ul>
|
||||
${this.connections.map(x => html`
|
||||
<li>
|
||||
<input type="button" @click=${() => tfrpc.rpc.closeConnection(x)} value="Close"></input>
|
||||
<tf-user id=${x} .users=${this.users}></tf-user>
|
||||
${self.render_room_peers(x)}
|
||||
</li>
|
||||
${this.connections.filter(x => x.tunnel === undefined).map(x => html`
|
||||
<li>${this.render_connection(x)}</li>
|
||||
`)}
|
||||
</ul>
|
||||
<h2>Stored Connections (WIP)</h2>
|
||||
|
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);
|
190
apps/ssb/tf-tab-news-feed.js
Normal file
190
apps/ssb/tf-tab-news-feed.js
Normal file
@ -0,0 +1,190 @@
|
||||
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 > ? 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,
|
||||
/*
|
||||
** Don't show messages more than a day into the future to prevent
|
||||
** messages with far-future timestamps from staying at the top forever.
|
||||
*/
|
||||
new Date().valueOf() + 24 * 60 * 60 * 1000,
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
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 = await this.decrypt([...more, ...this.messages]);
|
||||
}
|
||||
|
||||
async decrypt(messages) {
|
||||
let result = [];
|
||||
for (let message of messages) {
|
||||
let content;
|
||||
try {
|
||||
content = JSON.parse(message?.content);
|
||||
} catch {
|
||||
}
|
||||
if (typeof(content) === 'string') {
|
||||
let decrypted;
|
||||
try {
|
||||
decrypted = await tfrpc.rpc.try_decrypt(this.whoami, content);
|
||||
} catch {
|
||||
}
|
||||
if (decrypted) {
|
||||
try {
|
||||
message.decrypted = JSON.parse(decrypted);
|
||||
} catch {
|
||||
message.decrypted = decrypted;
|
||||
}
|
||||
}
|
||||
}
|
||||
result.push(message);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async add_messages(messages) {
|
||||
this.messages = await this.decrypt([...messages, ...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(this.decrypt.bind(this)).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);
|
@ -2,114 +2,6 @@ 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 = {};
|
||||
}
|
||||
|
||||
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} .drafts=${this.drafts} .expanded=${this.expanded}></tf-news>`;
|
||||
}
|
||||
}
|
||||
|
||||
class TfTabNewsElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
@ -120,7 +12,7 @@ class TfTabNewsElement extends LitElement {
|
||||
following: {type: Array},
|
||||
drafts: {type: Object},
|
||||
expanded: {type: Object},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
@ -141,12 +33,22 @@ class TfTabNewsElement extends LitElement {
|
||||
});
|
||||
}
|
||||
|
||||
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.renderRoot?.getElementById('news');
|
||||
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])));
|
||||
news.add_messages(Object.values(Object.fromEntries(this.unread.map(x => [x.id, x]))));
|
||||
this.dispatchEvent(new CustomEvent('refresh'));
|
||||
}
|
||||
}
|
||||
@ -193,6 +95,13 @@ class TfTabNewsElement extends LitElement {
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
@ -200,12 +109,11 @@ class TfTabNewsElement extends LitElement {
|
||||
<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>
|
||||
<div><tf-compose id="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-feed', TfTabNewsFeedElement);
|
||||
customElements.define('tf-tab-news', TfTabNewsElement);
|
||||
customElements.define('tf-tab-news', TfTabNewsElement);
|
||||
|
114
apps/ssb/tf-tab-query.js
Normal file
114
apps/ssb/tf-tab-query.js
Normal file
@ -0,0 +1,114 @@
|
||||
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
|
||||
import * as tfrpc from '/static/tfrpc.js';
|
||||
import {styles} from './tf-styles.js';
|
||||
|
||||
class TfTabQueryElement extends LitElement {
|
||||
static get properties() {
|
||||
return {
|
||||
whoami: {type: String},
|
||||
users: {type: Object},
|
||||
following: {type: Array},
|
||||
query: {type: String},
|
||||
expanded: {type: Object},
|
||||
results: {type: Array},
|
||||
error: {type: Object},
|
||||
duration: {type: Number},
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
let self = this;
|
||||
this.whoami = null;
|
||||
this.users = {};
|
||||
this.following = [];
|
||||
this.expanded = {};
|
||||
this.duration = undefined;
|
||||
}
|
||||
|
||||
async search(query) {
|
||||
console.log('Searching...', this.whoami, query);
|
||||
this.results = [];
|
||||
this.error = undefined;
|
||||
this.duration = undefined;
|
||||
let search = this.renderRoot.getElementById('search');
|
||||
if (search) {
|
||||
search.value = query;
|
||||
search.focus();
|
||||
}
|
||||
await tfrpc.rpc.setHash('#sql=' + encodeURIComponent(query));
|
||||
let start_time = new Date();
|
||||
try {
|
||||
this.results = await tfrpc.rpc.query(query, [])
|
||||
} catch (error) {
|
||||
this.error = error;
|
||||
}
|
||||
let end_time = new Date();
|
||||
this.duration = (end_time - start_time).valueOf();
|
||||
console.log('Done.');
|
||||
search = this.renderRoot.getElementById('search');
|
||||
if (search) {
|
||||
search.value = query;
|
||||
search.focus();
|
||||
}
|
||||
}
|
||||
|
||||
search_keydown(event) {
|
||||
if (event.keyCode == 13 && event.ctrlKey) {
|
||||
this.query = this.renderRoot.getElementById('search').value;
|
||||
event.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
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_results() {
|
||||
if (!this.results?.length) {
|
||||
return html`<div>No results.</div>`;
|
||||
} else {
|
||||
let keys = Object.keys(this.results[0]).sort();
|
||||
return html`<table style="width: 100%; max-width: 100%">
|
||||
<tr>${keys.map(key => html`<th>${key}</th>`)}</tr>
|
||||
${this.results.map(row => html`<tr>${keys.map(key => html`<td>${row[key]}</td>`)}</tr>`)}
|
||||
</table>`;
|
||||
}
|
||||
}
|
||||
|
||||
render_error() {
|
||||
if (this.error) {
|
||||
return html`<h2 style="color: red">${this.error.message}</h2>
|
||||
<pre style="color: red">${this.error.stack}</pre>`;
|
||||
}
|
||||
}
|
||||
|
||||
render() {
|
||||
if (this.query !== this.last_query) {
|
||||
this.last_query = this.query;
|
||||
this.search(this.query);
|
||||
}
|
||||
let self = this;
|
||||
return html`
|
||||
<div style="display: flex; flex-direction: row">
|
||||
<textarea id="search" rows=8 style="flex: 1" @keydown=${this.search_keydown}>${this.query}</textarea>
|
||||
<input type="button" value="Execute" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}></input>
|
||||
</div>
|
||||
<div ?hidden=${this.duration === undefined}>Took ${this.duration / 1000.0} seconds.</div>
|
||||
<div ?hidden=${this.duration !== undefined}>Executing...</div>
|
||||
${this.render_error()}
|
||||
${this.render_results()}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
customElements.define('tf-tab-query', TfTabQueryElement);
|
@ -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);
|
@ -7,7 +7,7 @@ class TfUserElement extends LitElement {
|
||||
return {
|
||||
id: {type: String},
|
||||
users: {type: Object},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
static styles = styles;
|
||||
@ -19,18 +19,23 @@ class TfUserElement extends LitElement {
|
||||
}
|
||||
|
||||
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}">
|
||||
<a target="_top" href=${'#' + this.id}>${this.users[this.id].name ?? this.id}</a>
|
||||
<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; word-wrap: anywhere">
|
||||
<a target="_top" href=${'#' + this.id}>${this.id}</a>
|
||||
<div style="display: inline-block; font-weight: bold">
|
||||
${name}
|
||||
</div>`;
|
||||
}
|
||||
}
|
||||
|
@ -1,9 +1,54 @@
|
||||
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);
|
||||
|
@ -1,3 +1,4 @@
|
||||
{
|
||||
"type": "tildefriends-app"
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "☑️"
|
||||
}
|
@ -114,13 +114,12 @@ class TodoListElement extends LitElement {
|
||||
@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>
|
||||
<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}</span>
|
||||
<span @click=${x => self.remove_item(item)} style="cursor: pointer">❎</span></div>
|
||||
<span @click=${x => self.editing = index}>${item.text || '(empty)'}</span>
|
||||
`;
|
||||
}
|
||||
}
|
||||
@ -175,7 +174,8 @@ class TodoListElement extends LitElement {
|
||||
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))}
|
||||
${(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>
|
||||
|
5
apps/welcome.json
Normal file
5
apps/welcome.json
Normal file
@ -0,0 +1,5 @@
|
||||
{
|
||||
"type": "tildefriends-app",
|
||||
"emoji": "👋",
|
||||
"previous": "&xc5LDQt9bWp6wrPxZhvSGiMXjweSK1YbO2DReM7/2ic=.sha256"
|
||||
}
|
5
apps/welcome/app.js
Normal file
5
apps/welcome/app.js
Normal file
@ -0,0 +1,5 @@
|
||||
async function main() {
|
||||
await app.setDocument(utf8Decode(getFile('index.html')));
|
||||
}
|
||||
|
||||
main();
|
6
apps/welcome/brands.min.css
vendored
Normal file
6
apps/welcome/brands.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
BIN
apps/welcome/fa-brands-400.ttf
Normal file
BIN
apps/welcome/fa-brands-400.ttf
Normal file
Binary file not shown.
BIN
apps/welcome/fa-brands-400.woff2
Normal file
BIN
apps/welcome/fa-brands-400.woff2
Normal file
Binary file not shown.
BIN
apps/welcome/fa-regular-400.ttf
Normal file
BIN
apps/welcome/fa-regular-400.ttf
Normal file
Binary file not shown.
BIN
apps/welcome/fa-regular-400.woff2
Normal file
BIN
apps/welcome/fa-regular-400.woff2
Normal file
Binary file not shown.
BIN
apps/welcome/fa-solid-900.ttf
Normal file
BIN
apps/welcome/fa-solid-900.ttf
Normal file
Binary file not shown.
BIN
apps/welcome/fa-solid-900.woff2
Normal file
BIN
apps/welcome/fa-solid-900.woff2
Normal file
Binary file not shown.
9
apps/welcome/fontawesome.min.css
vendored
Normal file
9
apps/welcome/fontawesome.min.css
vendored
Normal file
File diff suppressed because one or more lines are too long
173
apps/welcome/index.html
Normal file
173
apps/welcome/index.html
Normal file
@ -0,0 +1,173 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<link rel="stylesheet" href="w3.css">
|
||||
<link rel="stylesheet" href="fontawesome.min.css">
|
||||
<link rel="stylesheet" href="regular.min.css">
|
||||
<link rel="stylesheet" href="solid.min.css">
|
||||
<link rel="stylesheet" href="brands.min.css">
|
||||
|
||||
<style>
|
||||
body,h1,h2,h3,h4,h5 {font-family: "Poppins", sans-serif}
|
||||
body {font-size: 16px;}
|
||||
img {margin-bottom: -8px;}
|
||||
.mySlides {display: none;}
|
||||
</style>
|
||||
<base target="_top">
|
||||
</head>
|
||||
<body class="w3-content w3-black" style="max-width:1500px;">
|
||||
<!-- The App Section -->
|
||||
<div class="w3-padding-64 w3-white">
|
||||
<div class="w3-row-padding">
|
||||
<div class="w3-col l8 m6 w3-padding-32">
|
||||
<h1 class="w3-jumbo">
|
||||
<b>😎 Tilde Friends</b>
|
||||
</h1>
|
||||
<h1 class="w3-xxlarge w3-text-green"><b>Make apps and friends from the comfort of your web browser.</b></h1>
|
||||
<p>Tilde Friends is a platform for building, running, and sharing web applications.</p>
|
||||
<p>Available for lots of devices:
|
||||
<i class="fa-brands fa-linux w3-xlarge"></i>
|
||||
<i class="fa-brands fa-android w3-xlarge"></i>
|
||||
<i class="fa-brands fa-apple w3-xlarge"></i>
|
||||
<i class="fa fa-mobile-screen w3-xlarge"></i>
|
||||
<i class="fa-brands fa-windows w3-xlarge"></i>
|
||||
</p>
|
||||
<a class="w3-button w3-black w3-padding-large" href="https://www.tildefriends.net/~cory/releases/"><i class="fa fa-download"></i> Download</a>
|
||||
<a class="w3-button w3-black w3-padding-large" href="https://www.tildefriends.net/~cory/apps/"><i class="fa fa-link"></i> Try Some Apps</a>
|
||||
</div>
|
||||
<div class="w3-col l4 m6">
|
||||
<img src="tildefriends.png" class="w3-image w3-right w3-hide-small">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- SSB Section -->
|
||||
<div class="w3-light-grey">
|
||||
<div class="w3-row-padding w3-padding-64 ">
|
||||
<div class="w3-col l4 m6 s4">
|
||||
<a href="https://scuttlebutt.nz/"><img class="w3-image w3-round-large" src="ssb.png" alt="Secure Scuttlebutt"></a>
|
||||
</div>
|
||||
<div class="w3-col l8 m6" style="height: auto">
|
||||
<h1 class="w3-jumbo"><b>Built for Sharing</b></h1>
|
||||
<p>
|
||||
Tilde Friends participates in the <a href="https://scuttlebutt.nz/">Secure Scuttlebutt</a> distributed social network.
|
||||
</p>
|
||||
<p>
|
||||
Share apps with friends. Discover new apps made by enemies. Post pictures of your coffee. Or just lurk.
|
||||
</p>
|
||||
<p>
|
||||
The social network integration provides tools for connecting with other people world-wide
|
||||
while still allowing apps and everything to operate offline.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Editor Section -->
|
||||
<div class="w3-container w3-padding-64 w3-light-blue">
|
||||
<div class="w3-row-padding">
|
||||
<div class="w3-col l8 m6">
|
||||
<h1 class="w3-jumbo"><b>Edit Anything</b></h1>
|
||||
<i class="fa fa-pen-to-square w3-left w3-jumbo w3-text-gray" style="padding: 32px"></i>
|
||||
<p>
|
||||
See that <code><b>edit</b></code> link near the top left corner of this page? It's there for
|
||||
every Tilde Friends app, so you can modify and see your changes right away.
|
||||
</p>
|
||||
<p>
|
||||
It's kind of like a wiki, but for code!
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sandbox Section -->
|
||||
<div class="w3-padding-64 w3-grey">
|
||||
<div class="w3-row-padding">
|
||||
<div class="w3-col">
|
||||
<h1 class="w3-jumbo" style="text-align: right"><b>Sandbox Security</b></h1>
|
||||
<i class="fa fa-road-barrier w3-right w3-jumbo w3-text-yellow" style="padding: 32px"></i>
|
||||
<p>
|
||||
Tilde Friends tries to make sure apps can be trusted using similar techniques to how web
|
||||
browsers and operating systems do it.
|
||||
</p>
|
||||
<p>
|
||||
This is all a work in progress, and it varies by platform, so don't give it all your
|
||||
innermost secrets yet, but do kick its tires and
|
||||
<a href="mailto:cory@tildefriends.net">share</a> any surprises you find.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Technlology Section -->
|
||||
<div class="w3-container w3-padding-64 w3-light-grey w3-center">
|
||||
<h1 class="w3-jumbo"><b>Trusted Technology</b></h1>
|
||||
<p>Tilde Friends is built using boring, trusted tech.</p>
|
||||
<p>Though of course for building Tilde Friends apps, you are free to use whatever fits.</p>
|
||||
|
||||
<div class="w3-row" style="margin-top:64px">
|
||||
<a href="https://en.wikipedia.org/wiki/C_(programming_language)" class="w3-col s3">
|
||||
<i class="fa fa-c w3-text-blue w3-jumbo"></i>
|
||||
<p>C</p>
|
||||
</a>
|
||||
<a href="https://bellard.org/quickjs/" class="w3-col s3">
|
||||
<i class="fa-brands fa-js w3-text-orange w3-jumbo"></i>
|
||||
<p>QuickJS</p>
|
||||
</a>
|
||||
<a href="https://www.sqlite.org/" class="w3-col s3">
|
||||
<i class="fa fa-database w3-text-red w3-jumbo"></i>
|
||||
<p>SQLite</p>
|
||||
</a>
|
||||
<a href="https://libuv.org/" class="w3-col s3">
|
||||
<i class="fa fa-bolt w3-text-yellow w3-jumbo"></i>
|
||||
<p>libuv</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="w3-row" style="margin-top:64px">
|
||||
<a href="https://www.zlib.net/" class="w3-col s3">
|
||||
<i class="fa fa-file-zipper w3-text-cyan w3-jumbo"></i>
|
||||
<p>zlib</p>
|
||||
</a>
|
||||
<a href="https://doc.libsodium.org/" class="w3-col s3">
|
||||
<i class="fa fa-lock w3-text-purple w3-jumbo"></i>
|
||||
<p>libsodium</p>
|
||||
</a>
|
||||
<a href="https://www.openssl.org/" class="w3-col s3">
|
||||
<i class="fa fa-shield-halved w3-text-green w3-jumbo"></i>
|
||||
<p>OpenSSL </p>
|
||||
</a>
|
||||
<a href="https://github.com/ianlancetaylor/libbacktrace" class="w3-col s3">
|
||||
<i class="fa fa-burst w3-text-pink w3-jumbo"></i>
|
||||
<p>libbacktrace</p>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="w3-row" style="margin-top:64px">
|
||||
<a href="https://codemirror.net/5/" class="w3-col s3">
|
||||
<i class="fa fa-keyboard w3-text-indigo w3-jumbo"></i>
|
||||
<p>CodeMirror</p>
|
||||
</a>
|
||||
<a href="https://speedscope.app/" class="w3-col s3">
|
||||
<i class="fa fa-microscope w3-text-orange w3-jumbo"></i>
|
||||
<p>Speedscope</p>
|
||||
</a>
|
||||
<a href="https://lit.dev/" class="w3-col s3">
|
||||
<i class="fa fa-fire w3-text-cyan w3-jumbo"></i>
|
||||
<p>Lit</p>
|
||||
</a>
|
||||
<a href="https://www.gnu.org/software/make/" class="w3-col s3">
|
||||
<i class="fa fa-hammer w3-text-teal w3-jumbo"></i>
|
||||
<p>GNU Make</p>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
<footer class="w3-container w3-padding-32 w3-blue-grey w3-center w3-xlarge">
|
||||
<p class="w3-medium">This page and Tilde Friends itself was made by Cory mostly in coffee shops and a local pizza place.</p>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
6
apps/welcome/regular.min.css
vendored
Normal file
6
apps/welcome/regular.min.css
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-regular:normal 400 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:400;font-display:block;src:url(fa-regular-400.woff2) format("woff2"),url(fa-regular-400.ttf) format("truetype")}.fa-regular,.far{font-weight:400}
|
6
apps/welcome/solid.min.css
vendored
Normal file
6
apps/welcome/solid.min.css
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/*!
|
||||
* Font Awesome Free 6.4.2 by @fontawesome - https://fontawesome.com
|
||||
* License - https://fontawesome.com/license/free (Icons: CC BY 4.0, Fonts: SIL OFL 1.1, Code: MIT License)
|
||||
* Copyright 2023 Fonticons, Inc.
|
||||
*/
|
||||
:host,:root{--fa-style-family-classic:"Font Awesome 6 Free";--fa-font-solid:normal 900 1em/1 "Font Awesome 6 Free"}@font-face{font-family:"Font Awesome 6 Free";font-style:normal;font-weight:900;font-display:block;src:url(fa-solid-900.woff2) format("woff2"),url(fa-solid-900.ttf) format("truetype")}.fa-solid,.fas{font-weight:900}
|
BIN
apps/welcome/ssb.png
Normal file
BIN
apps/welcome/ssb.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 50 KiB |
BIN
apps/welcome/tildefriends.png
Normal file
BIN
apps/welcome/tildefriends.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 141 KiB |
235
apps/welcome/w3.css
Normal file
235
apps/welcome/w3.css
Normal file
@ -0,0 +1,235 @@
|
||||
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */
|
||||
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
|
||||
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
|
||||
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
|
||||
article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
|
||||
audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
|
||||
audio:not([controls]){display:none;height:0}[hidden],template{display:none}
|
||||
a{background-color:transparent}a:active,a:hover{outline-width:0}
|
||||
abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
|
||||
b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
|
||||
small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
|
||||
sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none}
|
||||
code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible}
|
||||
button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
|
||||
button,input{overflow:visible}button,select{text-transform:none}
|
||||
button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}
|
||||
button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
|
||||
button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
|
||||
fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
|
||||
legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
|
||||
[type=checkbox],[type=radio]{padding:0}
|
||||
[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
|
||||
[type=search]{-webkit-appearance:textfield;outline-offset:-2px}
|
||||
[type=search]::-webkit-search-decoration{-webkit-appearance:none}
|
||||
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
|
||||
/* End extract */
|
||||
html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden}
|
||||
h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
|
||||
.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
|
||||
h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
|
||||
hr{border:0;border-top:1px solid #eee;margin:20px 0}
|
||||
.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit}
|
||||
.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
|
||||
.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
|
||||
.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
|
||||
.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
|
||||
.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
|
||||
.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
|
||||
.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap}
|
||||
.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
|
||||
.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}
|
||||
.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
|
||||
.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
|
||||
.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
|
||||
.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
|
||||
.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
|
||||
.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
|
||||
.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
|
||||
.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
|
||||
.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
|
||||
.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
|
||||
.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
|
||||
.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
|
||||
.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
|
||||
.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
|
||||
.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
|
||||
.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
|
||||
.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
|
||||
.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
|
||||
.w3-main,#main{transition:margin-left .4s}
|
||||
.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
|
||||
.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
|
||||
.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
|
||||
.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
|
||||
.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
|
||||
.w3-bar .w3-button{white-space:normal}
|
||||
.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
|
||||
.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
|
||||
.w3-responsive{display:block;overflow-x:auto}
|
||||
.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
|
||||
.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
|
||||
.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
|
||||
.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
|
||||
.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
|
||||
.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
|
||||
@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
|
||||
.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
|
||||
.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
|
||||
@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
|
||||
.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
|
||||
.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
|
||||
.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
|
||||
.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
|
||||
.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
|
||||
.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
|
||||
.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
|
||||
@media (max-width:1205px){.w3-auto{max-width:95%}}
|
||||
@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
|
||||
.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}
|
||||
.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
|
||||
.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
|
||||
@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
|
||||
@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
|
||||
@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
|
||||
@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
|
||||
.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
|
||||
.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
|
||||
.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
|
||||
.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
|
||||
.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
|
||||
.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
|
||||
.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
|
||||
.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
|
||||
.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
|
||||
.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
|
||||
.w3-display-position{position:absolute}
|
||||
.w3-circle{border-radius:50%}
|
||||
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
|
||||
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
|
||||
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
|
||||
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
|
||||
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
|
||||
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
|
||||
.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
|
||||
.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
|
||||
.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
|
||||
.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
|
||||
.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
|
||||
.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
|
||||
.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
|
||||
.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
|
||||
.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
|
||||
.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
|
||||
.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
|
||||
.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
|
||||
.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
|
||||
.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
|
||||
.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
|
||||
.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
|
||||
.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
|
||||
.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
|
||||
.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
|
||||
.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
|
||||
.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
|
||||
.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
|
||||
.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
|
||||
.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
|
||||
.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
|
||||
.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
|
||||
.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
|
||||
.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
|
||||
.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
|
||||
.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
|
||||
.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
|
||||
.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
|
||||
.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
|
||||
.w3-left{float:left!important}.w3-right{float:right!important}
|
||||
.w3-button:hover{color:#000!important;background-color:#ccc!important}
|
||||
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
|
||||
.w3-hover-none:hover{box-shadow:none!important}
|
||||
/* Colors */
|
||||
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
|
||||
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
|
||||
.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
|
||||
.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
|
||||
.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
|
||||
.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
|
||||
.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
|
||||
.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
|
||||
.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
|
||||
.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
|
||||
.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
|
||||
.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
|
||||
.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
|
||||
.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
|
||||
.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
|
||||
.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
|
||||
.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
|
||||
.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
|
||||
.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
|
||||
.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
|
||||
.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
|
||||
.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
|
||||
.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
|
||||
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
|
||||
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
|
||||
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
|
||||
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
|
||||
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
|
||||
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
|
||||
.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
|
||||
.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
|
||||
.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
|
||||
.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
|
||||
.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
|
||||
.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
|
||||
.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
|
||||
.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
|
||||
.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
|
||||
.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
|
||||
.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
|
||||
.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
|
||||
.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
|
||||
.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
|
||||
.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
|
||||
.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
|
||||
.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
|
||||
.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
|
||||
.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
|
||||
.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
|
||||
.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
|
||||
.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
|
||||
.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
|
||||
.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
|
||||
.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
|
||||
.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
|
||||
.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
|
||||
.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
|
||||
.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
|
||||
.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
|
||||
.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
|
||||
.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
|
||||
.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
|
||||
.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
|
||||
.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
|
||||
.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
|
||||
.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
|
||||
.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
|
||||
.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
|
||||
.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
|
||||
.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
|
||||
.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
|
||||
.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
|
||||
.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
|
||||
.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
|
||||
.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
|
||||
.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
|
||||
.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
|
||||
.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
|
||||
.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
|
||||
.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
|
||||
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
|
||||
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
|
||||
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
|
||||
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}
|
14
core/app.js
14
core/app.js
@ -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) {
|
||||
@ -125,9 +124,14 @@ function socket(request, response, client) {
|
||||
options.credentials = credentials;
|
||||
options.packageOwner = packageOwner;
|
||||
options.packageName = packageName;
|
||||
options.url = message.url;
|
||||
let sessionId = makeSessionId();
|
||||
if (blobId) {
|
||||
process = await core.getSessionProcessBlob(blobId, sessionId, options);
|
||||
if (message.edit_only) {
|
||||
response.send(JSON.stringify({action: 'ready', edit_only: true}), 0x1);
|
||||
} else {
|
||||
process = await core.getSessionProcessBlob(blobId, sessionId, options);
|
||||
}
|
||||
}
|
||||
if (process) {
|
||||
process.app.readOutput(function(message) {
|
||||
@ -198,9 +202,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`,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
133
core/auth.html
133
core/auth.html
@ -2,18 +2,137 @@
|
||||
<html>
|
||||
<head>
|
||||
<title>Tilde Friends Sign-in</title>
|
||||
<script>
|
||||
function showHideConfirm() {
|
||||
document.getElementById("confirmPassword").style.display = document.getElementById("register").checked ? "block" : "none";
|
||||
}
|
||||
</script>
|
||||
<link type="text/css" rel="stylesheet" href="/static/style.css">
|
||||
<link type="image/png" rel="shortcut icon" href="/static/favicon.png">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<!--HEAD-->
|
||||
</head>
|
||||
<body>
|
||||
<h1 style="text-align: center">Tilde Friends Sign-in</h1>
|
||||
<div id="content"><!--SESSION--></div>
|
||||
<tf-auth id="auth"></tf-auth>
|
||||
<script>window.litDisableBundleWarning = true;</script>
|
||||
<script type="module">
|
||||
import {LitElement, html} from '/static/lit/lit-all.min.js';
|
||||
let g_data = $AUTH_DATA;
|
||||
let app = document.getElementById('auth');
|
||||
Object.assign(app, g_data);
|
||||
|
||||
class TfAuthElement extends LitElement {
|
||||
static get_properties() {
|
||||
return {
|
||||
name: {type: String},
|
||||
tab: {type: String},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.tab = 'login';
|
||||
}
|
||||
|
||||
tab_changed(name) {
|
||||
this.tab = name;
|
||||
this.requestUpdate();
|
||||
}
|
||||
|
||||
render() {
|
||||
let self = this;
|
||||
return html`
|
||||
<style>
|
||||
[name="tab"] {
|
||||
display: none;
|
||||
}
|
||||
[name="tab"]+label {
|
||||
background-color: #586e75;
|
||||
padding: 1em;
|
||||
display: inline-block;
|
||||
flex: 1 0;
|
||||
text-align: center;
|
||||
cursor: pointer;
|
||||
}
|
||||
[name="tab"]+label:hover {
|
||||
background-color: #dc322f;
|
||||
}
|
||||
[name="tab"]:checked+label {
|
||||
background-color: #93a1a1;
|
||||
border: 2px solid #eee8d5;
|
||||
}
|
||||
.error {
|
||||
color: #f00;
|
||||
border: 1px solid #f00;
|
||||
margin: 4px;
|
||||
padding: 8px;
|
||||
}
|
||||
form label {
|
||||
padding-top: 1em;
|
||||
font-weight: bold;
|
||||
}
|
||||
form input {
|
||||
font-size: x-large;
|
||||
padding: 4px;
|
||||
}
|
||||
input[type="submit"] {
|
||||
margin-top: 1em;
|
||||
margin-bottom: 1em;
|
||||
padding: 1em;
|
||||
}
|
||||
</style>
|
||||
<div style="display: flex; flex-direction: column; max-width: 1280px; margin: auto">
|
||||
<h1 ?hidden=${this.name}>Welcome.</h1>
|
||||
<h1 ?hidden=${this.name === undefined}>Welcome, ${this.name}.</h1>
|
||||
|
||||
<div style="display: flex; flex-direction: row; width: 100%">
|
||||
<input type="radio" name="tab" id="login" value="Login" ?checked=${this.tab == 'login'} @change=${() => self.tab_changed('login')}></input>
|
||||
<label for="login" id="login_label">Login</label>
|
||||
|
||||
<input type="radio" name="tab" id="register" value="Register" ?checked=${this.tab == 'register'} @change=${() => self.tab_changed('register')}></input>
|
||||
<label for="register" id="register_label">Register</label>
|
||||
|
||||
<input type="radio" name="tab" id="guest" value="Guest" ?checked=${this.tab == 'guest'} @change=${() => self.tab_changed('guest')}></input>
|
||||
<label for="guest" id="guest_label">Guest</label>
|
||||
|
||||
<input type="radio" name="tab" id="change" value="Change Password" ?checked=${this.tab == 'change'} @change=${() => self.tab_changed('change')}></input>
|
||||
<label for="change" id="change_label">Change Password</label>
|
||||
</div>
|
||||
|
||||
<div ?hidden=${this.tab != 'login' && this.tab != 'register' && this.tab != 'change'}>
|
||||
<div id="error" ?hidden=${this.error === undefined} class="error">
|
||||
${this.error}
|
||||
</div>
|
||||
<form method="POST" style="display: flex; flex-direction: column">
|
||||
<label for="name">Name:</label>
|
||||
<input type="text" id="name" name="name"></input>
|
||||
|
||||
<label for="password">${this.tab == 'change' ? 'Old ' : ''}Password:</label>
|
||||
<input type="password" id="password" name="password"></input>
|
||||
|
||||
<label ?hidden=${this.tab != 'change'} for="new_password">New Password:</label>
|
||||
<input ?hidden=${this.tab != 'change'} type="password" id="new_password" name="new_password"></input>
|
||||
|
||||
<label ?hidden=${this.tab != 'register' && this.tab != 'change'} for="confirm">Confirm ${this.tab == 'change' ? 'New ' : ''}Password:</label>
|
||||
<input ?hidden=${this.tab != 'register' && this.tab != 'change'} type="password" id="confirm" name="confirm"></input>
|
||||
|
||||
<input id="loginButton" type="submit" name="submit" value="Login"></input>
|
||||
<input type="hidden" name="register" value="${this.tab == 'register' ? 1 : 0}"></input>
|
||||
<input type="hidden" name="change" value="${this.tab == 'change' ? 1 : 0}"></input>
|
||||
</form>
|
||||
</div>
|
||||
<div ?hidden=${this.tab != 'guest'}>
|
||||
<form method="POST" style="display: flex; flex-direction: column">
|
||||
<input type="submit" id="guestButton" name="guestButton" value="Proceed as Guest"></input>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div ?hidden=${this.have_administrator} class="notice">
|
||||
There is currently no administrator. You will be made administrator.
|
||||
</div>
|
||||
|
||||
<h2>Code of Conduct</h2>
|
||||
<textarea readonly rows="20" cols="80" style="resize: none">${this.code_of_conduct}</textarea>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
customElements.define('tf-auth', TfAuthElement);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
107
core/auth.js
107
core/auth.js
@ -113,22 +113,37 @@ function getCookies(headers) {
|
||||
return cookies;
|
||||
}
|
||||
|
||||
function isNameValid(name) {
|
||||
let c = name.charAt(0);
|
||||
return ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z')) && name.split().map(x => x >= ('a' && x <= 'z') || x >= ('A' && x <= 'Z') || x >= ('0' && x <= '9'));
|
||||
}
|
||||
|
||||
function handler(request, response) {
|
||||
let session = getCookies(request.headers).session;
|
||||
if (request.uri == "/login") {
|
||||
let formData = form.decodeForm(request.query);
|
||||
if (query(request.headers)?.permissions?.authenticated) {
|
||||
if (formData.return) {
|
||||
response.writeHead(303, {"Location": formData.return});
|
||||
} else {
|
||||
response.writeHead(303, {"Location": (request.client.tls ? 'https://' : 'http://') + request.headers.host + '/', "Content-Length": "0"});
|
||||
}
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
let sessionIsNew = false;
|
||||
let loginError;
|
||||
|
||||
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") {
|
||||
let account = gDatabase.get("user:" + formData.name);
|
||||
account = account ? JSON.parse(account) : account;
|
||||
if (formData.register == "1") {
|
||||
if (formData.register == '1') {
|
||||
if (!account &&
|
||||
isNameValid(formData.name) &&
|
||||
formData.password == formData.confirm) {
|
||||
let users = new Set();
|
||||
let users_original = gDatabase.get('users');
|
||||
@ -145,12 +160,23 @@ function handler(request, response) {
|
||||
}
|
||||
session = makeJwt({name: formData.name});
|
||||
account = {password: hashPassword(formData.password)};
|
||||
gDatabase.set("user:" + formData.name, JSON.stringify(account));
|
||||
gDatabase.set('user:' + formData.name, JSON.stringify(account));
|
||||
if (noAdministrator()) {
|
||||
makeAdministrator(formData.name);
|
||||
}
|
||||
} else {
|
||||
loginError = "Error registering account.";
|
||||
loginError = 'Error registering account.';
|
||||
}
|
||||
} else if (formData.change == '1') {
|
||||
if (account &&
|
||||
isNameValid(formData.name) &&
|
||||
formData.new_password == formData.confirm &&
|
||||
verifyPassword(formData.password, account.password)) {
|
||||
session = makeJwt({name: formData.name});
|
||||
account = {password: hashPassword(formData.new_password)};
|
||||
gDatabase.set('user:' + formData.name, JSON.stringify(account));
|
||||
} else {
|
||||
loginError = 'Error changing password.';
|
||||
}
|
||||
} else {
|
||||
if (account &&
|
||||
@ -161,7 +187,7 @@ function handler(request, response) {
|
||||
makeAdministrator(formData.name);
|
||||
}
|
||||
} else {
|
||||
loginError = "Invalid username or password.";
|
||||
loginError = 'Invalid username or password.';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@ -170,7 +196,7 @@ function handler(request, response) {
|
||||
}
|
||||
}
|
||||
|
||||
let cookie = `session=${session}; path=/; Max-Age=${kRefreshInterval}; Secure; SameSite=Strict`;
|
||||
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});
|
||||
@ -178,53 +204,23 @@ function handler(request, response) {
|
||||
} else {
|
||||
File.readFile("core/auth.html").then(function(data) {
|
||||
let html = utf8Decode(data);
|
||||
let contents = "";
|
||||
|
||||
if (entry) {
|
||||
if (sessionIsNew) {
|
||||
contents += '<div>Welcome back, ' + entry.name + '.</div>\n';
|
||||
} else {
|
||||
contents += '<div>You are already logged in, ' + entry.name + '.</div>\n';
|
||||
}
|
||||
contents += '<div><a href="/login/logout">Logout</a></div>\n';
|
||||
} else {
|
||||
contents += '<form method="POST">\n';
|
||||
if (loginError) {
|
||||
contents += "<p>" + loginError + "</p>\n";
|
||||
}
|
||||
contents += '<div id="auth_greeting"><b>Halt. Who goes there?</b></div>\n'
|
||||
contents += '<div id="auth">\n';
|
||||
contents += '<div id="auth_login">\n'
|
||||
if (noAdministrator()) {
|
||||
contents += '<div class="notice">There is currently no administrator. You will be made administrator.</div>\n';
|
||||
}
|
||||
contents += '<div><label for="name">Name:</label> <input type="text" id="name" name="name" value=""></div>\n';
|
||||
contents += '<div><label for="password">Password:</label> <input type="password" id="password" name="password" value=""></div>\n';
|
||||
contents += '<div id="confirmPassword" style="display: none"><label for="confirm">Confirm:</label> <input type="password" id="confirm" name="confirm" value=""></div>\n';
|
||||
contents += '<div><input type="checkbox" id="register" name="register" value="1" onchange="showHideConfirm()"> <label for="register">Register a new account</label></div>\n';
|
||||
contents += '<div><input id="loginButton" type="submit" name="submit" value="Login"></div>\n';
|
||||
contents += '</div>';
|
||||
contents += '<div class="auth_or"> - or - </div>';
|
||||
contents += '<div id="auth_guest">\n';
|
||||
contents += '<input id="guestButton" type="submit" name="submit" value="Proceeed as Guest">\n';
|
||||
contents += '</div>\n';
|
||||
contents += '</div>\n';
|
||||
contents += '<div style="text-align: center">\n';
|
||||
contents += '<h2>Code of Conduct</h2>\n';
|
||||
contents += `<div><textarea readonly rows=20 cols=80>${core.globalSettings.code_of_conduct}</textarea></div>\n`;
|
||||
contents += '</div>\n';
|
||||
contents += '</form>';
|
||||
}
|
||||
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);
|
||||
let auth_data = {
|
||||
session_is_new: sessionIsNew,
|
||||
name: entry?.name,
|
||||
error: loginError,
|
||||
code_of_conduct: core.globalSettings.code_of_conduct,
|
||||
have_administrator: !noAdministrator(),
|
||||
};
|
||||
html = utf8Encode(html.replace('$AUTH_DATA', JSON.stringify(auth_data)));
|
||||
response.writeHead(200, {"Content-Type": "text/html; charset=utf-8", "Set-Cookie": cookie, "Content-Length": html.length});
|
||||
response.end(html);
|
||||
}).catch(function(error) {
|
||||
response.writeHead(404, {"Content-Type": "text/plain; charset=utf-8", "Connection": "close"});
|
||||
response.end("404 File not found");
|
||||
});
|
||||
}
|
||||
} 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"});
|
||||
@ -260,12 +256,17 @@ function query(headers) {
|
||||
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 };
|
||||
|
759
core/client.js
759
core/client.js
@ -1,14 +1,11 @@
|
||||
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 gOriginalInput;
|
||||
|
||||
let kErrorColor = "#dc322f";
|
||||
@ -26,6 +23,346 @@ 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 50 5em';
|
||||
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 id="login" href="/login/logout?return=${url() + hash()}">logout ${this.credentials.session.name}</a>`;
|
||||
} else {
|
||||
return html`<a id="login" 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)} style='min-width: 3em; min-height: 3em">Reset</button>
|
||||
</div>
|
||||
`)}
|
||||
<button @click=${() => this.show_permissions = false} style="min-width: 3em; min-height: 3em">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; align-items: center">
|
||||
<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 => [html`<span style="font-size: xx-small">${x.dataset.emoji}</span>`, 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},
|
||||
dropping: {type: Number},
|
||||
};
|
||||
}
|
||||
|
||||
constructor() {
|
||||
super();
|
||||
this.files = {};
|
||||
this.dropping = 0;
|
||||
}
|
||||
|
||||
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>`;
|
||||
}
|
||||
|
||||
async drop(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
this.dropping = 0;
|
||||
for (let file of event.dataTransfer.files) {
|
||||
let buffer = await file.arrayBuffer();
|
||||
let text = new TextDecoder('latin1').decode(buffer);
|
||||
gFiles[file.name] = {
|
||||
doc: new CodeMirror.Doc(text, guessMode(file.name)),
|
||||
buffer: buffer,
|
||||
generation: -1,
|
||||
isNew: true,
|
||||
};
|
||||
gCurrentFile = file.name;
|
||||
}
|
||||
openFile(gCurrentFile);
|
||||
updateFiles();
|
||||
}
|
||||
|
||||
drag_enter(event) {
|
||||
this.dropping++;
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
drag_leave(event) {
|
||||
this.dropping--;
|
||||
}
|
||||
|
||||
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 @drop=${this.drop} @dragenter=${this.drag_enter} @dragleave=${this.drag_leave}>
|
||||
${Object.keys(this.files).sort().map(x => self.render_file(x))}
|
||||
</div>
|
||||
<div
|
||||
?hidden=${this.dropping == 0}
|
||||
@drop=${this.drop} @dragenter=${this.drag_enter} @dragleave=${this.drag_leave}
|
||||
style="text-align: center; vertical-align: middle; outline: 16px solid red; margin: -8px; background-color: rgba(255, 0, 0, 0.5); position: absolute; left: 16px; top: 16px; width: calc(100% - 16px); height: calc(100% - 16px); z-index: 1000">
|
||||
Drop File(s)
|
||||
</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) => [50.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="max-width: 7.5em; max-height: 1.5em; margin: 0; padding: 0; background: #000" viewBox="0 0 50 10" 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()) {
|
||||
@ -81,12 +418,8 @@ function editing() {
|
||||
return document.getElementById("editPane").style.display != 'none';
|
||||
}
|
||||
|
||||
function toggleEdit() {
|
||||
if (editing()) {
|
||||
closeEditor();
|
||||
} else {
|
||||
edit();
|
||||
}
|
||||
function is_edit_only() {
|
||||
return window.location.search == '?editonly=1' || window.innerWidth < 1024;
|
||||
}
|
||||
|
||||
function edit() {
|
||||
@ -95,11 +428,8 @@ function edit() {
|
||||
}
|
||||
|
||||
window.localStorage.setItem('editing', '1');
|
||||
if (gSplit) {
|
||||
gSplit.destroy();
|
||||
gSplit = undefined;
|
||||
}
|
||||
gSplit = Split(['#editPane', '#viewPane'], {minSize: 0});
|
||||
document.getElementById("editPane").style.display = 'flex';
|
||||
document.getElementById('viewPane').style.display = is_edit_only() ? 'none' : 'flex';
|
||||
|
||||
ensureLoaded([
|
||||
{tagName: "script", attributes: {src: "/codemirror/codemirror.min.js"}},
|
||||
@ -107,6 +437,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"}},
|
||||
@ -118,6 +449,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.min.js"}},
|
||||
{tagName: "script", attributes: {src: "/codemirror/javascript-lint.min.js"}},
|
||||
], function() {
|
||||
load().catch(function(error) {
|
||||
alert(error);
|
||||
@ -126,40 +460,10 @@ 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')}`);
|
||||
}
|
||||
|
||||
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() {
|
||||
if (document.getElementById("statsPane").style.display == 'none') {
|
||||
stats();
|
||||
} else {
|
||||
closeStats();
|
||||
}
|
||||
}
|
||||
|
||||
function guessMode(name) {
|
||||
return name.endsWith(".js") ? "javascript" :
|
||||
name.endsWith(".html") ? "htmlmixed" :
|
||||
@ -169,13 +473,13 @@ function guessMode(name) {
|
||||
function loadFile(name, id) {
|
||||
return fetch('/' + id + '/view').then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Request failed: ' + response.status + ' ' + response.statusText);
|
||||
alert(`Request failed for ${name}: ${response.status} ${response.statusText}`);
|
||||
return 'missing file!';
|
||||
}
|
||||
return response.text();
|
||||
}).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]);
|
||||
}
|
||||
});
|
||||
@ -200,6 +504,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();
|
||||
@ -219,6 +530,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';
|
||||
@ -236,10 +549,7 @@ function load(path) {
|
||||
function closeEditor() {
|
||||
window.localStorage.setItem('editing', '0');
|
||||
document.getElementById("editPane").style.display = 'none';
|
||||
if (gSplit) {
|
||||
gSplit.destroy();
|
||||
gSplit = undefined;
|
||||
}
|
||||
document.getElementById('viewPane').style.display = 'flex';
|
||||
}
|
||||
|
||||
function explodePath() {
|
||||
@ -250,6 +560,9 @@ function save(save_to) {
|
||||
document.getElementById("save").disabled = true;
|
||||
if (gCurrentFile) {
|
||||
gFiles[gCurrentFile].doc = gEditor.getDoc();
|
||||
if (!gFiles[gCurrentFile].isNew && !gFiles[gCurrentFile].doc.isClean(gFiles[gCurrentFile].doc.changeGeneration())) {
|
||||
delete gFiles[gCurrentFile].buffer;
|
||||
}
|
||||
}
|
||||
|
||||
let save_path = save_to;
|
||||
@ -265,17 +578,18 @@ function save(save_to) {
|
||||
let promises = [];
|
||||
for (let name of Object.keys(gFiles)) {
|
||||
let file = gFiles[name];
|
||||
if (file.doc.isClean(file.generation)) {
|
||||
if (!file.isNew && file.doc.isClean(file.generation)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
delete file.id;
|
||||
delete file.isNew;
|
||||
promises.push(fetch('/save', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'text/plain',
|
||||
'Content-Type': 'application/binary',
|
||||
},
|
||||
body: file.doc.getValue(),
|
||||
body: file.buffer ?? file.doc.getValue(),
|
||||
}).then(function(response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Saving "' + name + '": ' + response.status + ' ' + response.statusText);
|
||||
@ -293,6 +607,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));
|
||||
@ -325,6 +640,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();
|
||||
@ -390,7 +713,8 @@ function api_localStorageGet(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');
|
||||
@ -417,11 +741,13 @@ function api_requestPermission(permission, id) {
|
||||
|
||||
const k_options = [
|
||||
{
|
||||
id: 'allow',
|
||||
text: '✅ Allow',
|
||||
grant: ['allow once', 'allow'],
|
||||
|
||||
},
|
||||
{
|
||||
id: 'deny',
|
||||
text: '❌ Deny',
|
||||
grant: ['deny once', 'deny'],
|
||||
},
|
||||
@ -431,20 +757,20 @@ function api_requestPermission(permission, id) {
|
||||
div = document.createElement('div');
|
||||
for (let option of k_options) {
|
||||
let button = document.createElement('button');
|
||||
button.style.minWidth = '3em';
|
||||
button.style.minHeight = '3em';
|
||||
button.innerText = option.text;
|
||||
button.id = option.id;
|
||||
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);
|
||||
});
|
||||
}
|
||||
|
||||
@ -456,85 +782,20 @@ 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;
|
||||
updateLogin();
|
||||
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;
|
||||
document.getElementById('viewPane').style.display = message.edit_only ? 'none' : 'flex';
|
||||
send({action: 'enableStats', enabled: true});
|
||||
} else if (message && message.action == "ping") {
|
||||
send({action: "pong"});
|
||||
} else if (message && message.action == "stats") {
|
||||
@ -555,8 +816,8 @@ function _receive_websocket_message(message) {
|
||||
tls_malloc_percent: {group: 'memory', name: 'tls'},
|
||||
uv_malloc_percent: {group: 'memory', name: 'uv'},
|
||||
|
||||
messages_stored: {group: 'stored', name: 'messages'},
|
||||
blobs_stored: {group: 'stored', name: 'blobs'},
|
||||
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'},
|
||||
@ -566,56 +827,16 @@ function _receive_websocket_message(message) {
|
||||
};
|
||||
const k_colors = ['#0f0', '#88f', '#ff0', '#f0f', '#0ff', '#f00', '#888'];
|
||||
let graph_key = k_groups[key]?.group || key;
|
||||
let graph = gGraphs[graph_key];
|
||||
if (!graph) {
|
||||
graph = {
|
||||
chart: new SmoothieChart({
|
||||
millisPerPixel: 100,
|
||||
minValue: 0,
|
||||
grid: {
|
||||
millisPerLine: 1000,
|
||||
verticalSections: 10,
|
||||
},
|
||||
tooltip: true,
|
||||
}),
|
||||
canvas: document.createElement('canvas'),
|
||||
title: document.createElement('div'),
|
||||
series: [],
|
||||
};
|
||||
gGraphs[graph_key] = graph;
|
||||
graph.canvas.width = 240;
|
||||
graph.canvas.height = 64;
|
||||
graph.title.innerText = graph_key;
|
||||
graph.title.style.flex = '0';
|
||||
document.getElementById('graphs').appendChild(graph.title);
|
||||
document.getElementById('graphs').appendChild(graph.canvas);
|
||||
graph.chart.streamTo(graph.canvas, 1000);
|
||||
if (['cpu', 'rpc', 'store', 'memory'].indexOf(graph_key) != -1) {
|
||||
let line = document.getElementsByTagName('tf-navigation')[0].get_spark_line(graph_key, { max: 100 });
|
||||
line.dataset.emoji = {
|
||||
'cpu': '💻',
|
||||
'rpc': '🔁',
|
||||
'store': '💾',
|
||||
'memory': '🐏',
|
||||
}[graph_key];
|
||||
line.append(key, message.stats[key]);
|
||||
}
|
||||
|
||||
let timeseries = gTimeSeries[key];
|
||||
if (!timeseries) {
|
||||
let is_multi = key != graph_key || graph.series.length > 1;
|
||||
timeseries = new TimeSeries();
|
||||
gTimeSeries[key] = timeseries;
|
||||
graph.chart.addTimeSeries(timeseries, {lineWidth: 2, strokeStyle: is_multi ? k_colors[graph.series.length] : '#fff'});
|
||||
graph.series.push(k_groups[key]?.name || key);
|
||||
if (is_multi) {
|
||||
while (graph.title.firstChild) {
|
||||
graph.title.removeChild(graph.title.firstChild);
|
||||
}
|
||||
function makeColoredText(text, color) {
|
||||
let element = document.createElement('span');
|
||||
element.style.color = color;
|
||||
element.innerText = text;
|
||||
return element;
|
||||
}
|
||||
graph.title.appendChild(makeColoredText(graph_key + ':', '#fff'));
|
||||
for (let series of graph.series) {
|
||||
graph.title.appendChild(makeColoredText(' ' + series, k_colors[graph.series.indexOf(series)]));
|
||||
}
|
||||
}
|
||||
}
|
||||
timeseries.append(now, message.stats[key]);
|
||||
}
|
||||
} else if (message &&
|
||||
message.message === 'tfrpc' &&
|
||||
@ -641,27 +862,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) {
|
||||
@ -674,39 +876,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();
|
||||
let input = document.getElementById("input");
|
||||
if (event.type == "dragover") {
|
||||
if (!input.classList.contains("drop")) {
|
||||
input.classList.add("drop");
|
||||
gOriginalInput = input.value;
|
||||
input.value = "drop file to upload";
|
||||
}
|
||||
} else {
|
||||
input.classList.remove("drop");
|
||||
input.value = gOriginalInput;
|
||||
}
|
||||
}
|
||||
|
||||
function fixImage(sourceData, maxWidth, maxHeight, callback) {
|
||||
let result = sourceData;
|
||||
let image = new Image();
|
||||
@ -736,52 +905,6 @@ function sendImage(image) {
|
||||
});
|
||||
}
|
||||
|
||||
function fileDropRead(event) {
|
||||
sendImage(event.target.result);
|
||||
}
|
||||
|
||||
function fileDrop(event) {
|
||||
dragHover(event);
|
||||
|
||||
let done = false;
|
||||
if (!done) {
|
||||
let files = event.target.files || event.dataTransfer.files;
|
||||
for (let i = 0; i < files.length; i++) {
|
||||
let file = files[i];
|
||||
if (file.type.substring(0, "image/".length) == "image/") {
|
||||
let reader = new FileReader();
|
||||
reader.onloadend = fileDropRead;
|
||||
reader.readAsDataURL(file);
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!done) {
|
||||
let html = event.dataTransfer.getData("text/html");
|
||||
let match = /<img.*src="([^"]+)"/.exec(html);
|
||||
if (match) {
|
||||
sendImage(match[1]);
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!done) {
|
||||
let text = event.dataTransfer.getData("text/plain");
|
||||
if (text) {
|
||||
send(text);
|
||||
done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function enableDragDrop() {
|
||||
let body = document.body;
|
||||
body.addEventListener("dragover", dragHover);
|
||||
body.addEventListener("dragleave", dragHover);
|
||||
body.addEventListener("drop", fileDrop);
|
||||
}
|
||||
|
||||
function hashChange() {
|
||||
send({event: 'hashChange', hash: window.location.hash});
|
||||
}
|
||||
@ -831,10 +954,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);
|
||||
}
|
||||
|
||||
@ -858,6 +983,8 @@ function connectSocket(path) {
|
||||
gSocket.send(JSON.stringify({
|
||||
action: "hello",
|
||||
path: connect_path,
|
||||
url: window.location.href,
|
||||
edit_only: editing() && is_edit_only(),
|
||||
api: Object.entries(k_api).map(([key, value]) => [].concat([key], value.args)),
|
||||
}));
|
||||
}
|
||||
@ -892,35 +1019,23 @@ function openFile(name) {
|
||||
let oldDoc = gEditor.swapDoc(newDoc);
|
||||
if (gFiles[gCurrentFile]) {
|
||||
gFiles[gCurrentFile].doc = oldDoc;
|
||||
if (!gFiles[gCurrentFile].isNew && gFiles[gCurrentFile].doc.isClean(oldDoc.generation)) {
|
||||
delete gFiles[gCurrentFile].buffer;
|
||||
}
|
||||
}
|
||||
gCurrentFile = name;
|
||||
updateFiles();
|
||||
gEditor.focus();
|
||||
}
|
||||
|
||||
function onFileClicked(event) {
|
||||
openFile(event.target.textContent);
|
||||
}
|
||||
|
||||
function updateFiles() {
|
||||
let node = document.getElementById("files");
|
||||
while (node.firstChild) {
|
||||
node.removeChild(node.firstChild);
|
||||
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;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
gEditor.focus();
|
||||
}
|
||||
|
||||
@ -953,27 +1068,14 @@ window.addEventListener("load", function() {
|
||||
window.addEventListener("message", message, false);
|
||||
window.addEventListener("online", connectSocket);
|
||||
document.getElementById("name").value = window.location.pathname;
|
||||
document.getElementById('edit_link').addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
toggleEdit();
|
||||
});
|
||||
document.getElementById('show_permissions_link').addEventListener('click', () => showPermissions());
|
||||
document.getElementById('files_hide').addEventListener('click', () => hideFiles());
|
||||
document.getElementById('files_show').addEventListener('click', () => showFiles());
|
||||
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();
|
||||
event.preventDefault();
|
||||
trace();
|
||||
});
|
||||
document.getElementById('stats_button').addEventListener('click', function(event) {
|
||||
event.preventDefault();
|
||||
toggleStats();
|
||||
});
|
||||
document.getElementById('new_file_button').addEventListener('click', () => newFile());
|
||||
document.getElementById('remove_file_button').addEventListener('click', () => removeFile());
|
||||
for (let tag of document.getElementsByTagName('a')) {
|
||||
if (tag.accessKey) {
|
||||
tag.classList.add('tooltip_parent');
|
||||
@ -998,7 +1100,6 @@ window.addEventListener("load", function() {
|
||||
tag.appendChild(tooltip);
|
||||
}
|
||||
}
|
||||
enableDragDrop();
|
||||
connectSocket(window.location.pathname);
|
||||
|
||||
if (window.localStorage.getItem('editing') == '1') {
|
||||
@ -1006,14 +1107,4 @@ window.addEventListener("load", function() {
|
||||
} else {
|
||||
closeEditor();
|
||||
}
|
||||
if (window.localStorage.getItem('files') == '1') {
|
||||
showFiles();
|
||||
} else {
|
||||
hideFiles();
|
||||
}
|
||||
if (window.localStorage.getItem('stats') == '1') {
|
||||
stats();
|
||||
} else {
|
||||
closeStats();
|
||||
}
|
||||
});
|
||||
|
424
core/core.js
424
core/core.js
@ -1,11 +1,46 @@
|
||||
import * as auth from './auth.js';
|
||||
import * as app from './app.js';
|
||||
import * as auth from './auth.js';
|
||||
import * as form from './form.js';
|
||||
import * as http from './http.js';
|
||||
import * as httpd from './httpd.js';
|
||||
|
||||
let gProcessIndex = 0;
|
||||
let gProcesses = {};
|
||||
let gStatsTimer = false;
|
||||
|
||||
const k_mime_types = {
|
||||
'css': 'text/css',
|
||||
'html': 'text/html',
|
||||
'js': 'text/javascript',
|
||||
'json': 'text/json',
|
||||
'map': 'application/json',
|
||||
'svg': 'image/svg+xml',
|
||||
};
|
||||
|
||||
const k_magic_bytes = [
|
||||
{bytes: [0xff, 0xd8, 0xff, 0xdb], type: 'image/jpeg'},
|
||||
{bytes: [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01], type: 'image/jpeg'},
|
||||
{bytes: [0xff, 0xd8, 0xff, 0xee], type: 'image/jpeg'},
|
||||
{bytes: [0xff, 0xd8, 0xff, 0xe1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00], type: 'image/jpeg'},
|
||||
{bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], type: 'image/png'},
|
||||
{bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], type: 'image/gif'},
|
||||
{bytes: [0x47, 0x49, 0x46, 0x38, 0x39, 0x61], type: 'image/gif'},
|
||||
{bytes: [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50], type: 'image/webp'},
|
||||
{bytes: [0x3c, 0x73, 0x76, 0x67], type: 'image/svg+xml'},
|
||||
{bytes: [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32], type: 'audio/mpeg'},
|
||||
{bytes: [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d], type: 'video/mp4'},
|
||||
{bytes: [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32], type: 'video/mp4'},
|
||||
];
|
||||
|
||||
let k_static_files = [
|
||||
{uri: '/', path: 'index.html', type: 'text/html; charset=UTF-8'},
|
||||
{uri: '/style.css', type: 'text/css; charset=UTF-8'},
|
||||
{uri: '/favicon.png', type: 'image/png'},
|
||||
{uri: '/client.js', type: 'text/javascript; charset=UTF-8'},
|
||||
{uri: '/tfrpc.js', type: 'text/javascript; charset=UTF-8', headers: {'Access-Control-Allow-Origin': 'null'}},
|
||||
{uri: '/robots.txt', type: 'text/plain; charset=UTF-8'},
|
||||
];
|
||||
|
||||
const k_global_settings = {
|
||||
index: {
|
||||
type: 'string',
|
||||
@ -32,6 +67,21 @@ const k_global_settings = {
|
||||
default_value: undefined,
|
||||
description: 'If connecting by HTTP and HTTPS is configured, Location header prefix (ie, "https://example.com")',
|
||||
},
|
||||
fetch_hosts: {
|
||||
type: 'string',
|
||||
default_value: undefined,
|
||||
description: 'Comma-separated list of host names to which HTTP fetch requests are allowed. None if empty.',
|
||||
},
|
||||
blob_fetch_age_seconds: {
|
||||
type: 'integer',
|
||||
default_value: (platform() == 'android' || platform() == 'iphone' ? 0.5 * 365 * 24 * 60 * 60 : undefined),
|
||||
description: 'Only blobs mentioned more recently than this age will be automatically fetched.',
|
||||
},
|
||||
blob_expire_age_seconds: {
|
||||
type: 'integer',
|
||||
default_value: (platform() == 'android' || platform() == 'iphone' ? 1.0 * 365 * 24 * 60 * 60 : undefined),
|
||||
description: 'Blobs older than this will be automatically deleted.',
|
||||
},
|
||||
};
|
||||
|
||||
let gGlobalSettings = {
|
||||
@ -45,8 +95,8 @@ function printError(out, error) {
|
||||
out.print(error.fileName + ":" + error.lineNumber + ": " + error.message);
|
||||
out.print(error.stackTrace);
|
||||
} else {
|
||||
for (let i in error) {
|
||||
out.print(i);
|
||||
for (let [k, v] of Object.entries(error)) {
|
||||
out.print(k, v);
|
||||
}
|
||||
out.print(error.toString());
|
||||
}
|
||||
@ -158,7 +208,9 @@ async function getProcessBlob(blobId, key, options) {
|
||||
process.credentials = options.credentials || {};
|
||||
process.task = new Task();
|
||||
process.eventHandlers = {};
|
||||
process.app = new app.App();
|
||||
if (!options?.script || options?.script === 'app.js') {
|
||||
process.app = new app.App();
|
||||
}
|
||||
process.lastActive = Date.now();
|
||||
process.lastPing = null;
|
||||
process.timeout = options.timeout;
|
||||
@ -242,7 +294,7 @@ async function getProcessBlob(blobId, key, options) {
|
||||
throw Error(`Permission denied: ${permission}.`);
|
||||
}
|
||||
|
||||
} else {
|
||||
} else if (process.app) {
|
||||
return process.app.makeFunction(['requestPermission'])(permission).then(function(value) {
|
||||
if (value == 'allow') {
|
||||
storePermission(user, options.packageOwner, options.packageName, permission, true);
|
||||
@ -259,8 +311,11 @@ async function getProcessBlob(blobId, key, options) {
|
||||
}
|
||||
throw Error(`Permission denied: ${permission}.`);
|
||||
});
|
||||
} else {
|
||||
throw Error(`Permission denied: ${permission}.`);
|
||||
}
|
||||
},
|
||||
url: options?.url,
|
||||
}
|
||||
};
|
||||
if (process.credentials?.permissions?.administration) {
|
||||
@ -283,7 +338,7 @@ async function getProcessBlob(blobId, key, options) {
|
||||
print('Done.');
|
||||
};
|
||||
imports.core.deleteUser = function(user) {
|
||||
return imports.core.permissionTest('delete_user').then(function() {
|
||||
return Promise.resolve(imports.core.permissionTest('delete_user')).then(function() {
|
||||
let db = new Database('auth');
|
||||
|
||||
db.remove('user:' + user);
|
||||
@ -309,14 +364,23 @@ async function getProcessBlob(blobId, key, options) {
|
||||
imports.app[api[0]] = process.app.makeFunction(api);
|
||||
}
|
||||
}
|
||||
for (let [name, f] of Object.entries(options?.imports || {})) {
|
||||
imports[name] = f;
|
||||
}
|
||||
process.task.onPrint = function(args) {
|
||||
imports.app.print(...args);
|
||||
if (imports.app) {
|
||||
imports.app.print(...args);
|
||||
}
|
||||
};
|
||||
process.task.onError = function(error) {
|
||||
try {
|
||||
process.app.makeFunction(['error'])(error);
|
||||
} catch(e) {
|
||||
print(e);
|
||||
if (process.app) {
|
||||
process.app.makeFunction(['error'])(error);
|
||||
} else {
|
||||
printError({print: print}, error);
|
||||
}
|
||||
} catch (e) {
|
||||
printError({print: print}, error);
|
||||
}
|
||||
};
|
||||
imports.ssb = Object.fromEntries(Object.keys(ssb).map(key => [key, ssb[key].bind(ssb)]));
|
||||
@ -338,7 +402,7 @@ async function getProcessBlob(blobId, key, options) {
|
||||
if (process.credentials &&
|
||||
process.credentials.session &&
|
||||
process.credentials.session.name) {
|
||||
return imports.core.permissionTest('ssb_append').then(function() {
|
||||
return Promise.resolve(imports.core.permissionTest('ssb_append')).then(function() {
|
||||
return ssb.appendMessageWithIdentity(process.credentials.session.name, id, message);
|
||||
});
|
||||
}
|
||||
@ -357,6 +421,16 @@ async function getProcessBlob(blobId, key, options) {
|
||||
return ssb.privateMessageDecrypt(process.credentials.session.name, id, message);
|
||||
}
|
||||
};
|
||||
imports.ssb.setServerFollowingMe = function(id, following) {
|
||||
if (process.credentials &&
|
||||
process.credentials.session &&
|
||||
process.credentials.session.name) {
|
||||
return ssb.setServerFollowingMe(process.credentials.session.name, id, following);
|
||||
}
|
||||
};
|
||||
imports.fetch = function(url, options) {
|
||||
return http.fetch(url, options, gGlobalSettings.fetch_hosts);
|
||||
}
|
||||
|
||||
if (process.credentials &&
|
||||
process.credentials.session &&
|
||||
@ -395,7 +469,7 @@ async function getProcessBlob(blobId, key, options) {
|
||||
try {
|
||||
let appObject = JSON.parse(appSource);
|
||||
if (appObject.type == "tildefriends-app") {
|
||||
appSourceName = 'app.js';
|
||||
appSourceName = options?.script ?? 'app.js';
|
||||
let id = appObject.files[appSourceName];
|
||||
let blob = await getBlobOrContent(id);
|
||||
appSource = utf8Decode(blob);
|
||||
@ -408,19 +482,23 @@ async function getProcessBlob(blobId, key, options) {
|
||||
printError({print: print}, e);
|
||||
}
|
||||
broadcastEvent('onSessionBegin', [getUser(process, process)]);
|
||||
resolveReady(process);
|
||||
if (process.app) {
|
||||
process.app.send({action: "ready"});
|
||||
process.app.send({action: "ready", version: version()});
|
||||
process.sendPermissions();
|
||||
}
|
||||
await process.task.execute({name: appSourceName, source: appSource});
|
||||
resolveReady(process);
|
||||
} catch (error) {
|
||||
if (process?.task?.onError) {
|
||||
process.task.onError(error);
|
||||
if (process.app) {
|
||||
if (process?.task?.onError) {
|
||||
process.task.onError(error);
|
||||
} else {
|
||||
printError({print: print}, error);
|
||||
}
|
||||
} else {
|
||||
printError({print: print}, error);
|
||||
}
|
||||
rejectReady();
|
||||
rejectReady(error);
|
||||
}
|
||||
}
|
||||
return process;
|
||||
@ -435,20 +513,11 @@ function setGlobalSettings(settings) {
|
||||
}
|
||||
}
|
||||
|
||||
let kStaticFiles = [
|
||||
{uri: '/', path: 'index.html', type: 'text/html; charset=UTF-8'},
|
||||
{uri: '/style.css', type: 'text/css; charset=UTF-8'},
|
||||
{uri: '/favicon.png', type: 'image/png'},
|
||||
{uri: '/client.js', type: 'text/javascript; charset=UTF-8'},
|
||||
{uri: '/tfrpc.js', type: 'text/javascript; charset=UTF-8', headers: {'Access-Control-Allow-Origin': 'null'}},
|
||||
{uri: '/robots.txt', type: 'text/plain; charset=UTF-8'},
|
||||
];
|
||||
|
||||
function startsWithBytes(data, bytes) {
|
||||
if (data.byteLength >= bytes.length) {
|
||||
let dataBytes = new Uint8Array(data.slice(0, bytes.length));
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
if (dataBytes[i] != bytes[i] && bytes[i] !== null) {
|
||||
if (dataBytes[i] !== bytes[i] && bytes[i] !== null) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
@ -457,16 +526,16 @@ function startsWithBytes(data, bytes) {
|
||||
}
|
||||
|
||||
async function staticFileHandler(request, response, blobId, uri) {
|
||||
for (let i in kStaticFiles) {
|
||||
if (uri === kStaticFiles[i].uri) {
|
||||
let path = kStaticFiles[i].path || uri.substring(1);
|
||||
let type = kStaticFiles[i].type || guessType(path);
|
||||
for (let i in k_static_files) {
|
||||
if (uri === k_static_files[i].uri) {
|
||||
let path = k_static_files[i].path || uri.substring(1);
|
||||
let type = k_static_files[i].type || guessTypeFromName(path);
|
||||
|
||||
let stat = await File.stat('core/' + path);
|
||||
let id = `${stat.mtime}_${stat.size}`;
|
||||
|
||||
if (request.headers['if-none-match'] === '"' + id + '"') {
|
||||
response.writeHead(304, {});
|
||||
response.writeHead(304, {'Content-Length': '0'});
|
||||
response.end();
|
||||
} else {
|
||||
let data = await File.readFile('core/' + path);
|
||||
@ -476,7 +545,7 @@ async function staticFileHandler(request, response, blobId, uri) {
|
||||
'Content-Length': data.byteLength,
|
||||
'etag': '"' + id + '"',
|
||||
},
|
||||
kStaticFiles[i].headers || {}));
|
||||
k_static_files[i].headers || {}));
|
||||
response.end(data);
|
||||
}
|
||||
return;
|
||||
@ -487,14 +556,6 @@ async function staticFileHandler(request, response, blobId, uri) {
|
||||
response.end("File not found");
|
||||
}
|
||||
|
||||
const k_mime_types = {
|
||||
'json': 'text/json',
|
||||
'js': 'text/javascript',
|
||||
'html': 'text/html',
|
||||
'css': 'text/css',
|
||||
'map': 'application/json',
|
||||
};
|
||||
|
||||
async function staticDirectoryHandler(request, response, directory, uri) {
|
||||
let filename = uri || 'index.html';
|
||||
if (filename.indexOf('..') != -1) {
|
||||
@ -508,7 +569,7 @@ async function staticDirectoryHandler(request, response, directory, uri) {
|
||||
let id = `${stat.mtime}_${stat.size}`;
|
||||
|
||||
if (request.headers['if-none-match'] === '"' + id + '"') {
|
||||
response.writeHead(304, {});
|
||||
response.writeHead(304, {'Content-Length': '0'});
|
||||
response.end();
|
||||
} else {
|
||||
let data = await File.readFile(directory + filename);
|
||||
@ -536,40 +597,25 @@ async function wellKnownHandler(request, response, path) {
|
||||
}
|
||||
}
|
||||
|
||||
function sendData(response, data, type, headers) {
|
||||
if (data) {
|
||||
if (startsWithBytes(data, [0xff, 0xd8, 0xff, 0xdb]) ||
|
||||
startsWithBytes(data, [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]) ||
|
||||
startsWithBytes(data, [0xff, 0xd8, 0xff, 0xee]) ||
|
||||
startsWithBytes(data, [0xff, 0xd8, 0xff, 0xe1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00])) {
|
||||
response.writeHead(200, Object.assign({"Content-Type": "image/jpeg", "Content-Length": data.byteLength}, headers || {}));
|
||||
response.end(data);
|
||||
} else if (startsWithBytes(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
|
||||
response.writeHead(200, Object.assign({"Content-Type": "image/png", "Content-Length": data.byteLength}, headers || {}));
|
||||
response.end(data);
|
||||
} else if (startsWithBytes(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
|
||||
startsWithBytes(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) {
|
||||
response.writeHead(200, Object.assign({"Content-Type": "image/gif", "Content-Length": data.byteLength}, headers || {}));
|
||||
response.end(data);
|
||||
} else if (startsWithBytes(data, [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50])) {
|
||||
response.writeHead(200, Object.assign({"Content-Type": "image/webp", "Content-Length": data.byteLength}, headers || {}));
|
||||
response.end(data);
|
||||
} else if (startsWithBytes(data, [0x3c, 0x73, 0x76, 0x67])) {
|
||||
response.writeHead(200, Object.assign({"Content-Type": "image/svg+xml", "Content-Length": data.byteLength}, headers || {}));
|
||||
response.end(data);
|
||||
} else if (startsWithBytes(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
|
||||
response.writeHead(200, Object.assign({"Content-Type": "audio/mpeg", "Content-Length": data.byteLength}, headers || {}));
|
||||
response.end(data);
|
||||
} else if (startsWithBytes(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]) ||
|
||||
startsWithBytes(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
|
||||
response.writeHead(200, Object.assign({"Content-Type": "video/mp4", "Content-Length": data.byteLength}, headers || {}));
|
||||
response.end(data);
|
||||
} else {
|
||||
response.writeHead(200, Object.assign({"Content-Type": type || "application/binary", "Content-Length": data.byteLength}, headers || {}));
|
||||
response.end(data);
|
||||
function guessTypeFromName(path) {
|
||||
let extension = path.split('.').pop();
|
||||
return k_mime_types[extension];
|
||||
}
|
||||
|
||||
function guessTypeFromMagicBytes(data) {
|
||||
for (let magic of k_magic_bytes) {
|
||||
if (startsWithBytes(data, magic.bytes)) {
|
||||
return magic.type;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function sendData(response, data, type, headers, status_code) {
|
||||
if (data) {
|
||||
response.writeHead(status_code ?? 200, Object.assign({"Content-Type": type || guessTypeFromMagicBytes(data) || "application/binary", "Content-Length": data.byteLength}, headers || {}));
|
||||
response.end(data);
|
||||
} else {
|
||||
response.writeHead(404, Object.assign({"Content-Type": "text/plain; charset=utf-8", "Content-Length": "File not found".length}, headers || {}));
|
||||
response.writeHead(status_code ?? 404, Object.assign({"Content-Type": "text/plain; charset=utf-8", "Content-Length": "File not found".length}, headers || {}));
|
||||
response.end("File not found");
|
||||
}
|
||||
}
|
||||
@ -584,35 +630,58 @@ async function getBlobOrContent(id) {
|
||||
}
|
||||
}
|
||||
|
||||
function guessType(path) {
|
||||
const k_extension_to_type = {
|
||||
'css': 'text/css',
|
||||
'html': 'text/html',
|
||||
'js': 'text/javascript',
|
||||
'svg': 'image/svg+xml',
|
||||
};
|
||||
let extension = path.split('.').pop();
|
||||
return k_extension_to_type[extension];
|
||||
let g_handler_index = 0;
|
||||
async function useAppHandler(response, handler_blob_id, path, query, headers, packageOwner, packageName) {
|
||||
print('useAppHandler', packageOwner, packageName);
|
||||
let do_resolve;
|
||||
let promise = new Promise(async function(resolve, reject) {
|
||||
do_resolve = resolve;
|
||||
});
|
||||
let process;
|
||||
let result;
|
||||
try {
|
||||
process = await getProcessBlob(handler_blob_id, 'handler_' + g_handler_index++, {
|
||||
script: 'handler.js',
|
||||
imports: {
|
||||
request: {
|
||||
path: path,
|
||||
query: query,
|
||||
},
|
||||
respond: do_resolve,
|
||||
},
|
||||
credentials: auth.query(headers),
|
||||
packageOwner: packageOwner,
|
||||
packageName: packageName,
|
||||
});
|
||||
await process.ready;
|
||||
|
||||
result = await promise;
|
||||
} finally {
|
||||
if (process?.task) {
|
||||
await process.task.kill();
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function blobHandler(request, response, blobId, uri) {
|
||||
for (let i in kStaticFiles) {
|
||||
if (uri === kStaticFiles[i].uri && kStaticFiles[i].path) {
|
||||
let stat = await File.stat('core/' + kStaticFiles[i].path);
|
||||
for (let i in k_static_files) {
|
||||
if (uri === k_static_files[i].uri && k_static_files[i].path) {
|
||||
let stat = await File.stat('core/' + k_static_files[i].path);
|
||||
let id = `${stat.mtime}_${stat.size}`;
|
||||
|
||||
if (request.headers['if-none-match'] === '"' + id + '"') {
|
||||
response.writeHead(304, {});
|
||||
response.writeHead(304, {'Content-Length': '0'});
|
||||
response.end();
|
||||
} else {
|
||||
let data = await File.readFile('core/' + kStaticFiles[i].path);
|
||||
let data = await File.readFile('core/' + k_static_files[i].path);
|
||||
response.writeHead(200, Object.assign(
|
||||
{
|
||||
'Content-Type': kStaticFiles[i].type,
|
||||
'Content-Type': k_static_files[i].type,
|
||||
'Content-Length': data.byteLength,
|
||||
'etag': '"' + id + '"',
|
||||
},
|
||||
kStaticFiles[i].headers || {}));
|
||||
k_static_files[i].headers || {}));
|
||||
response.end(data);
|
||||
}
|
||||
return;
|
||||
@ -621,7 +690,7 @@ async function blobHandler(request, response, blobId, uri) {
|
||||
|
||||
if (!uri) {
|
||||
response.writeHead(303, {"Location": (request.client.tls ? 'https://' : 'http://') + request.headers.host + blobId + '/', "Content-Length": "0"});
|
||||
response.end(data);
|
||||
response.end();
|
||||
return;
|
||||
}
|
||||
|
||||
@ -629,11 +698,19 @@ async function blobHandler(request, response, blobId, uri) {
|
||||
if (uri == "/view") {
|
||||
let data;
|
||||
let match;
|
||||
let query = form.decodeForm(request.query);
|
||||
let headers = {
|
||||
'Content-Security-Policy': 'sandbox',
|
||||
};
|
||||
if (query.filename && query.filename.match(/^[A-Za-z0-9\.-]*$/)) {
|
||||
headers['Content-Disposition'] = `attachment; filename=${query.filename}`;
|
||||
}
|
||||
if (match = /^\/\~(\w+)\/(\w+)$/.exec(blobId)) {
|
||||
let id = await new Database(match[1]).get('path:' + match[2]);
|
||||
if (id) {
|
||||
if (request.headers['if-none-match'] === '"' + id + '"') {
|
||||
response.writeHead(304, {});
|
||||
headers['Content-Length'] = '0';
|
||||
response.writeHead(304, headers);
|
||||
response.end();
|
||||
} else {
|
||||
data = await getBlobOrContent(id);
|
||||
@ -641,29 +718,30 @@ async function blobHandler(request, response, blobId, uri) {
|
||||
let appObject = JSON.parse(data);
|
||||
data = appObject.files[match[3]];
|
||||
}
|
||||
sendData(response, data, undefined, {etag: '"' + id + '"'});
|
||||
sendData(response, data, undefined, Object.assign({etag: '"' + id + '"'}, headers));
|
||||
}
|
||||
} else {
|
||||
if (request.headers['if-none-match'] === '"' + blobId + '"') {
|
||||
response.writeHead(304, {});
|
||||
headers['Content-Length'] = '0';
|
||||
response.writeHead(304, headers);
|
||||
response.end();
|
||||
} else {
|
||||
sendData(response, data, undefined, {etag: '"' + blobId + '"'});
|
||||
sendData(response, data, undefined, Object.assign({etag: '"' + blobId + '"'}, headers));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (request.headers['if-none-match'] === '"' + blobId + '"') {
|
||||
response.writeHead(304, {});
|
||||
headers['Content-Length'] = '0';
|
||||
response.writeHead(304, headers);
|
||||
response.end();
|
||||
} else {
|
||||
data = await getBlobOrContent(blobId);
|
||||
sendData(response, data, undefined, {etag: '"' + blobId + '"'});
|
||||
sendData(response, data, undefined, Object.assign({etag: '"' + blobId + '"'}, headers));
|
||||
}
|
||||
}
|
||||
} else if (uri == "/save") {
|
||||
let match;
|
||||
if (match = /^\/\~(\w+)\/(\w+)$/.exec(blobId)) {
|
||||
let newBlobId = await ssb.blobStore(request.body);
|
||||
let user = match[1];
|
||||
let appName = match[2];
|
||||
let credentials = auth.query(request.headers);
|
||||
@ -671,6 +749,25 @@ async function blobHandler(request, response, blobId, uri) {
|
||||
(credentials.session.name == user ||
|
||||
(credentials.permissions.administration && user == 'core'))) {
|
||||
let database = new Database(user);
|
||||
|
||||
let app_object = JSON.parse(utf8Decode(request.body));
|
||||
let previous_id = database.get('path:' + appName);
|
||||
if (previous_id) {
|
||||
try {
|
||||
let previous_object = JSON.parse(utf8Decode(await ssb.blobGet(previous_id)));
|
||||
delete previous_object.previous;
|
||||
delete app_object.previous;
|
||||
if (JSON.stringify(previous_object) == JSON.stringify(app_object)) {
|
||||
response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8"});
|
||||
response.end("/" + previous_id);
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
app_object.previous = previous_id;
|
||||
let newBlobId = await ssb.blobStore(JSON.stringify(app_object));
|
||||
|
||||
let apps = new Set();
|
||||
let apps_original = database.get('apps');
|
||||
try {
|
||||
@ -685,14 +782,13 @@ async function blobHandler(request, response, blobId, uri) {
|
||||
database.set('apps', apps);
|
||||
}
|
||||
database.set('path:' + appName, newBlobId);
|
||||
response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8"});
|
||||
response.end("/" + newBlobId);
|
||||
} else {
|
||||
response.writeHead(401, {"Content-Type": "text/plain; charset=utf-8"});
|
||||
response.end("401 Unauthorized");
|
||||
return;
|
||||
}
|
||||
|
||||
response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8"});
|
||||
response.end("/" + newBlobId);
|
||||
} else if (blobId === '') {
|
||||
let newBlobId = await ssb.blobStore(request.body);
|
||||
response.writeHead(200, {"Content-Type": "text/plain; charset=utf-8"});
|
||||
@ -732,43 +828,58 @@ async function blobHandler(request, response, blobId, uri) {
|
||||
response.end('OK');
|
||||
} else {
|
||||
let data;
|
||||
let type;
|
||||
let headers;
|
||||
let match;
|
||||
let id;
|
||||
let app_id = blobId;
|
||||
let packageOwner;
|
||||
let packageName;
|
||||
if (match = /^\/\~(\w+)\/(\w+)$/.exec(blobId)) {
|
||||
packageOwner = match[1];
|
||||
packageName = match[2];
|
||||
let db = new Database(match[1]);
|
||||
let id = await db.get('path:' + match[2]);
|
||||
if (id) {
|
||||
if (request.headers['if-none-match'] && request.headers['if-none-match'] == '"' + id + '"') {
|
||||
headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
};
|
||||
response.writeHead(304, headers);
|
||||
response.end();
|
||||
} else {
|
||||
data = utf8Decode(await getBlobOrContent(id));
|
||||
let appObject = JSON.parse(data);
|
||||
data = appObject.files[uri.substring(1)];
|
||||
data = await getBlobOrContent(data);
|
||||
type = guessType(uri);
|
||||
headers = {
|
||||
'ETag': '"' + id + '"',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
};
|
||||
sendData(response, data, type, headers);
|
||||
}
|
||||
app_id = await db.get('path:' + match[2]);
|
||||
}
|
||||
|
||||
let app_object = JSON.parse(utf8Decode(await getBlobOrContent(app_id)));
|
||||
id = app_object.files[uri.substring(1)];
|
||||
if (!id && app_object.files['handler.js']) {
|
||||
let answer;
|
||||
try {
|
||||
answer = await useAppHandler(response, app_id, uri.substring(1), request.query ? form.decodeForm(request.query) : undefined, request.headers, packageOwner, packageName);
|
||||
} catch (error) {
|
||||
data = utf8Encode(`Internal Server Error\n\n${error?.message}\n${error?.stack}`);
|
||||
response.writeHead(500, {'Content-Type': 'text/plain; charset=utf-8', 'Content-Length': data.length});
|
||||
response.end(data);
|
||||
return;
|
||||
}
|
||||
if (answer && typeof answer.data == 'string') {
|
||||
answer.data = utf8Encode(answer.data);
|
||||
}
|
||||
sendData(response, answer?.data, answer?.content_type, Object.assign(answer?.headers ?? {}, {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Content-Security-Policy': 'sandbox',
|
||||
}), answer.status_code);
|
||||
} else if (id) {
|
||||
if (request.headers['if-none-match'] && request.headers['if-none-match'] == '"' + id + '"') {
|
||||
let headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Content-Security-Policy': 'sandbox',
|
||||
'Content-Length': '0',
|
||||
};
|
||||
response.writeHead(304, headers);
|
||||
response.end();
|
||||
} else {
|
||||
let headers = {
|
||||
'ETag': '"' + id + '"',
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
'Content-Security-Policy': 'sandbox',
|
||||
};
|
||||
data = await getBlobOrContent(id);
|
||||
let type = guessTypeFromName(uri) || guessTypeFromMagicBytes(data);
|
||||
sendData(response, data, type, headers);
|
||||
}
|
||||
} else {
|
||||
data = utf8Decode(await getBlobOrContent(blobId));
|
||||
let appObject = JSON.parse(data);
|
||||
data = appObject.files[uri.substring(1)];
|
||||
data = await getBlobOrContent(data);
|
||||
headers = {
|
||||
'Access-Control-Allow-Origin': '*',
|
||||
};
|
||||
sendData(response, data, type, headers);
|
||||
sendData(response, data, undefined, {});
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -791,6 +902,11 @@ async function loadSettings() {
|
||||
} catch (error) {
|
||||
print("Settings not found in database:", error);
|
||||
}
|
||||
for (let [key, value] of Object.entries(k_global_settings)) {
|
||||
if (data[key] === undefined) {
|
||||
data[key] = value.default_value;
|
||||
}
|
||||
}
|
||||
gGlobalSettings = data;
|
||||
}
|
||||
|
||||
@ -815,6 +931,16 @@ function enableStats(process, enabled) {
|
||||
}
|
||||
}
|
||||
|
||||
function stringResponse(response, data) {
|
||||
let bytes = utf8Encode(data);
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": bytes.byteLength.toString(),
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
return response.end(bytes);
|
||||
}
|
||||
|
||||
loadSettings().then(function() {
|
||||
httpd.all("/login", auth.handler);
|
||||
httpd.all("", function(request, response) {
|
||||
@ -826,40 +952,28 @@ loadSettings().then(function() {
|
||||
return blobHandler(request, response, match[1], match[2]);
|
||||
} else if (match = /^\/([&\%][^\.]{44}(?:\.\w+)?)(\/?.*)/.exec(request.uri)) {
|
||||
return blobHandler(request, response, match[1], match[2]);
|
||||
} else if (match = /^\/static(\/.*)/.exec(request.uri)) {
|
||||
return staticFileHandler(request, response, null, match[1]);
|
||||
} else if (match = /^\/static\/lit\/([\.\w-/]*)$/.exec(request.uri)) {
|
||||
return staticDirectoryHandler(request, response, 'deps/lit/', match[1]);
|
||||
} else if (match = /^\/codemirror\/([\.\w-/]*)$/.exec(request.uri)) {
|
||||
return staticDirectoryHandler(request, response, 'deps/codemirror/', match[1]);
|
||||
} else if (match = /^\/speedscope\/([\.\w-/]*)$/.exec(request.uri)) {
|
||||
return staticDirectoryHandler(request, response, 'deps/speedscope/', match[1]);
|
||||
} else if (match = /^\/split\/([\.\w-/]*)$/.exec(request.uri)) {
|
||||
return staticDirectoryHandler(request, response, 'deps/split/', match[1]);
|
||||
} else if (match = /^\/smoothie\/([\.\w-/]*)$/.exec(request.uri)) {
|
||||
return staticDirectoryHandler(request, response, 'deps/smoothie/', match[1]);
|
||||
} else if (match = /^\/static(\/.*)/.exec(request.uri)) {
|
||||
return staticFileHandler(request, response, null, match[1]);
|
||||
} else if (request.uri == "/robots.txt") {
|
||||
return staticFileHandler(request, response, null, request.uri);
|
||||
} else if (match = /^(.*)(\/(?:save|delete)?)$/.exec(request.uri)) {
|
||||
return blobHandler(request, response, match[1], match[2]);
|
||||
} else if (match = /^\/trace$/.exec(request.uri)) {
|
||||
let data = trace();
|
||||
response.writeHead(200, {"Content-Type": "application/json; charset=utf-8", "Content-Length": data.length.toString()});
|
||||
return response.end(data);
|
||||
return stringResponse(response, trace());
|
||||
} else if (match = /^\/disconnections$/.exec(request.uri)) {
|
||||
let data = utf8Encode(JSON.stringify(disconnectionsDebug(), null, 2));
|
||||
response.writeHead(200, {"Content-Type": "application/json; charset=utf-8", "Content-Length": data.byteLength.toString()});
|
||||
return response.end(data);
|
||||
return stringResponse(response, JSON.stringify(disconnectionsDebug(), null, 2));
|
||||
} else if (match = /^\/debug$/.exec(request.uri)) {
|
||||
let data = JSON.stringify(getDebug(), null, 2);
|
||||
response.writeHead(200, {"Content-Type": "application/json; charset=utf-8", "Content-Length": data.length.toString()});
|
||||
return response.end(data);
|
||||
return stringResponse(response, JSON.stringify(getDebug(), null, 2));
|
||||
} else if (match = /^\/hitches$/.exec(request.uri)) {
|
||||
return stringResponse(response, JSON.stringify(getHitches(), null, 2));
|
||||
} else if (match = /^\/mem$/.exec(request.uri)) {
|
||||
let data = JSON.stringify(getAllocations(), null, 2);
|
||||
response.writeHead(200, {
|
||||
"Content-Type": "application/json; charset=utf-8",
|
||||
"Content-Length": data.length.toString(),
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
});
|
||||
return response.end(data);
|
||||
} else if (request.uri == "/robots.txt") {
|
||||
return blobHandler(request, response, null, request.uri);
|
||||
return stringResponse(response, JSON.stringify(getAllocations(), null, 2));
|
||||
} else if ((match = /^\/.well-known\/(.*)/.exec(request.uri)) && request.uri.indexOf("..") == -1) {
|
||||
return wellKnownHandler(request, response, match[1]);
|
||||
} else {
|
||||
|
86
core/http.js
Normal file
86
core/http.js
Normal file
@ -0,0 +1,86 @@
|
||||
function parseUrl(url) {
|
||||
// XXX: Hack.
|
||||
let match = url.match(new RegExp("(\\w+)://([^/:]+)(?::(\\d+))?(.*)"));
|
||||
return {
|
||||
protocol: match[1],
|
||||
host: match[2],
|
||||
path: match[4],
|
||||
port: match[3] ? parseInt(match[3]) : match[1] == "http" ? 80 : 443,
|
||||
};
|
||||
}
|
||||
|
||||
function parseResponse(data) {
|
||||
let firstLine;
|
||||
let headers = {};
|
||||
while (true) {
|
||||
let endLine = data.indexOf('\r\n');
|
||||
let line = data.substring(0, endLine);
|
||||
data = data.substring(endLine + 2);
|
||||
if (!line.length) {
|
||||
break;
|
||||
} else if (!firstLine) {
|
||||
firstLine = line;
|
||||
} else {
|
||||
let colon = line.indexOf(":");
|
||||
headers[line.substring(colon)] = line.substring(colon + 1);
|
||||
}
|
||||
}
|
||||
return {body: data};
|
||||
}
|
||||
|
||||
export function fetch(url, options, allowed_hosts) {
|
||||
let parsed = parseUrl(url);
|
||||
return new Promise(function(resolve, reject) {
|
||||
if ((allowed_hosts ?? []).indexOf(parsed.host) == -1) {
|
||||
throw new Error(`fetch() request to host ${parsed.host} is not allowed.`);
|
||||
}
|
||||
let socket = new Socket();
|
||||
let buffer = new Uint8Array(0);
|
||||
|
||||
return socket.connect(parsed.host, parsed.port).then(function() {
|
||||
socket.read(function(data) {
|
||||
if (data && data.length) {
|
||||
let newBuffer = new Uint8Array(buffer.length + data.length);
|
||||
newBuffer.set(buffer, 0);
|
||||
newBuffer.set(data, buffer.length);
|
||||
buffer = newBuffer;
|
||||
} else {
|
||||
let result = parseHttpResponse(buffer);
|
||||
if (!result) {
|
||||
reject(new Exception('Parse failed.'));
|
||||
}
|
||||
if (typeof result == 'number') {
|
||||
if (result == -2) {
|
||||
reject('Incomplete request.');
|
||||
} else {
|
||||
reject('Bad request.');
|
||||
}
|
||||
} else if (typeof result == 'object') {
|
||||
resolve({
|
||||
body: buffer.slice(result.bytes_parsed),
|
||||
status: result.status,
|
||||
message: result.message,
|
||||
headers: result.headers,
|
||||
});
|
||||
} else {
|
||||
reject(new Exception('Unexpected parse result.'));
|
||||
}
|
||||
resolve(parseResponse(utf8Decode(buffer)));
|
||||
}
|
||||
});
|
||||
|
||||
if (parsed.port == 443) {
|
||||
return socket.startTls();
|
||||
}
|
||||
}).then(function() {
|
||||
let body = typeof options?.body == 'string' ? utf8Encode(options.body) : (options.body || new Uint8Array(0));
|
||||
let headers = utf8Encode(`${options?.method ?? 'GET'} ${parsed.path} HTTP/1.0\r\nHost: ${parsed.host}\r\nConnection: close\r\nContent-Length: ${body.length}\r\n\r\n`);
|
||||
let fullRequest = new Uint8Array(headers.length + body.length);
|
||||
fullRequest.set(headers, 0);
|
||||
fullRequest.set(body, headers.length);
|
||||
socket.write(fullRequest);
|
||||
}).catch(function(error) {
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user