Compare commits
	
		
			249 Commits
		
	
	
		
			v0.0.15
			...
			b3f095b61f
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| b3f095b61f | |||
| a5004c8ba9 | |||
| 7d9b1b508b | |||
| 5e265dfc83 | |||
| 3a43d6f8ac | |||
| 11a6649847 | |||
| 7caf4a0173 | |||
| 385524352c | |||
| 5ca5323782 | |||
| ba6da856bb | |||
| c0e72246cc | |||
| c7ab5447ea | |||
| 5fdd461159 | |||
| 421955f2a0 | |||
| a28f6985ed | |||
| 8244dddab7 | |||
| a5ca436eaa | |||
| d7fc1c2c88 | |||
| 382627ef8d | |||
| 17667b4cf8 | |||
| 5231ec22e7 | |||
| 929ae1b709 | |||
| f01f7a5ab9 | |||
| a2dce833f8 | |||
| de6c7a4fd4 | |||
| 4edee0f7f6 | |||
| 988a807fa4 | |||
| 5258e4253d | |||
| 09ba86dec5 | |||
| 78d8a1aa23 | |||
| 22def15209 | |||
| 4cbda7a849 | |||
| be85a620ef | |||
| 0b07b678b4 | |||
| 4733ce9287 | |||
| 48d6bf4c15 | |||
| 8c759bcbac | |||
| b5ed7014f6 | |||
| 6cd9dea186 | |||
| 202b416acf | |||
| 93d46f5610 | |||
| c5ddf3ac99 | |||
| a9cb913a47 | |||
| b7b5d4f1a5 | |||
| a947396bad | |||
| d528bc808e | |||
| c6fd05c2cf | |||
| d6bb9d311a | |||
| 53b4cbbf8c | |||
| 628716ec28 | |||
| bd14168627 | |||
| 96037d4da6 | |||
| 5448e773d8 | |||
| 848ef21c7c | |||
| 2ecae7da93 | |||
| d9ce569eb9 | |||
| eacaf392b1 | |||
| ce16592b6a | |||
| 295d76d354 | |||
| 23b3c998bd | |||
| b5e966c9a1 | |||
| 96cb6f4b12 | |||
| e2c0f82ec0 | |||
| dbf28c03e6 | |||
| 26165e30de | |||
| c52331a23a | |||
| 8007e71e1d | |||
| 28d08e013f | |||
| 64bbd383de | |||
| 8a9f53102b | |||
| 0412b97170 | |||
| c8b8a8fc03 | |||
| 95d3090b9b | |||
| 49129ee6dd | |||
| 6a7ecb0d4a | |||
| 1ceeed1007 | |||
| a7922ff44e | |||
| a421604ed5 | |||
| 7d182db32f | |||
| c5cb9979d3 | |||
| b9a73106ed | |||
| c674cca482 | |||
| 81d1228b92 | |||
| 6ae61d5b81 | |||
| 9cb872eec2 | |||
| 68e8c010b7 | |||
| 9671413906 | |||
| 4c8d24c319 | |||
| e50144bd34 | |||
| 9f3171e3f1 | |||
| cc92748747 | |||
| 0a0b0c1adb | |||
| 92a74026a6 | |||
| 3fa1c6c420 | |||
| b04eccdbda | |||
| 9ce30dee70 | |||
| 3c0b680b8e | |||
| 895356897b | |||
| 9164be2f37 | |||
| 5385264f94 | |||
| 610e756c07 | |||
| 15c9f8f458 | |||
| fb704a5b83 | |||
| fdda628be8 | |||
| 2b45d8aa05 | |||
| 0e2fc65301 | |||
| e8ef7e74de | |||
| c32e1b9583 | |||
| 9d0f6ec155 | |||
| 855d603795 | |||
| af25782185 | |||
| e5ba51b80a | |||
| 5e240de677 | |||
| 418cfac0e3 | |||
| 9d09607013 | |||
| eddf25b622 | |||
| 537a8654fa | |||
| 9de33d06d2 | |||
| 0e5f320664 | |||
| 88d8e60511 | |||
| 439f07162e | |||
| efe2b6cbd9 | |||
| 0aa1ed9464 | |||
| cb94ed6a2a | |||
| cf187ee46b | |||
| 3e71fc20fd | |||
| f3601321f7 | |||
| 540059368c | |||
| 7ce89123f7 | |||
| e3c7c86212 | |||
| 794804e27f | |||
| 6d89c1da6e | |||
| d059554464 | |||
| 3a392d4a9f | |||
| e3071b372a | |||
| 18bd279b0c | |||
| 5b93db7463 | |||
| 5b7e5eb91b | |||
| 78ca383e3c | |||
| c1eed9ada3 | |||
| 8d6feb5394 | |||
| 42994f8977 | |||
| f0a871e1f8 | |||
| a710c30572 | |||
| c991763b00 | |||
| 72dae14f87 | |||
| 5800340762 | |||
| c5f5adcac6 | |||
| 591642efb3 | |||
| 6182ffa1d4 | |||
| 402a898d96 | |||
| 13d43d8319 | |||
| 7bcdbd3813 | |||
| 60ada22674 | |||
| 637119d46d | |||
| 40f3da6a65 | |||
| f4697fe7f7 | |||
| 3bc18b9021 | |||
| c21581aefa | |||
| 165f25db69 | |||
| 9aa0617aa1 | |||
| ddce88dce6 | |||
| 6aa2bce2be | |||
| a43c1d3d1e | |||
| 1ed0e817e8 | |||
| 709ca55e65 | |||
| 8c13f5dbba | |||
| 4cb82d81b7 | |||
| 0c42921387 | |||
| 70a3e7fc7d | |||
| d5267be38c | |||
| 8e7e0ed490 | |||
| 8cf2837725 | |||
| 63ae186c76 | |||
| dbf5c7b832 | |||
| bfbfc01e99 | |||
| 8fa9d0e843 | |||
| 2d3e108fd9 | |||
| 7822b30dcb | |||
| 2701b7d04e | |||
| e361c3f975 | |||
| 260706c172 | |||
| 390668ec34 | |||
| 1d5cdf9607 | |||
| a4bf3542e0 | |||
| df82cfe66b | |||
| f23414adaf | |||
| 41024ddb79 | |||
| 53f9547cc5 | |||
| 4bfd9de100 | |||
| c01e00d77d | |||
| 825191c08f | |||
| 9dc6670795 | |||
| 1db8eee9f7 | |||
| 1bc50cb62c | |||
| 450b07fd08 | |||
| 12c7515ee8 | |||
| ed65da4340 | |||
| d9d2917cf5 | |||
| ce5ca1875b | |||
| 4f869252a2 | |||
| 17b92126de | |||
| 6e88c44229 | |||
| 6c3d338c12 | |||
| 4ebd44cb4e | |||
| 75cb9f7fd2 | |||
| eacca9d2ab | |||
| d0e11bc68b | |||
| 1958623a7a | |||
| 498d8b6520 | |||
| a12f2fec5a | |||
| 22bf046643 | |||
| dca48fae36 | |||
| 9af4068bb6 | |||
| 2992d8ec12 | |||
| 33dd2560e0 | |||
| aeb5c6ee25 | |||
| 08a2436b8f | |||
| fbc3cfeda4 | |||
| c8812b1add | |||
| 8d82e80639 | |||
| ed741d53d7 | |||
| 685754895b | |||
| e7791d38ff | |||
| 9f14653001 | |||
| 6c5a7b0751 | |||
| 51a327c52d | |||
| 5a978bb30d | |||
| 6801758cb3 | |||
| 14de3dd9e5 | |||
| ed2d57fb4b | |||
| e87acc6286 | |||
| 0de932bc9e | |||
| d021d9f757 | |||
| eb5da26004 | |||
| 6765254f43 | |||
| e98802f5b2 | |||
| af54b6483e | |||
| 96167c3167 | |||
| eecfdf482f | |||
| 7ceb865206 | |||
| b919670706 | |||
| 72120b8842 | |||
| 1e53c08d9d | |||
| 2d1b6a09e9 | |||
| 7f661d9af9 | |||
| 81c66bdddd | |||
| 4bd46a1657 | |||
| 244a752ae1 | 
							
								
								
									
										20
									
								
								.clang-format
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								.clang-format
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,20 @@ | |||||||
|  | # Format Style Options - Created with Clang Power Tools | ||||||
|  | --- | ||||||
|  | BasedOnStyle: WebKit | ||||||
|  | AlignEscapedNewlines: DontAlign | ||||||
|  | AlignOperands: DontAlign | ||||||
|  | AllowShortCaseLabelsOnASingleLine: false | ||||||
|  | AllowShortFunctionsOnASingleLine: false | ||||||
|  | BreakBeforeBinaryOperators: None | ||||||
|  | BreakBeforeBraces: Allman | ||||||
|  | ColumnLimit: 180 | ||||||
|  | ContinuationIndentWidth: 4 | ||||||
|  | IndentCaseBlocks: true | ||||||
|  | IndentWidth: 4 | ||||||
|  | MaxEmptyLinesToKeep: 1 | ||||||
|  | ObjCBlockIndentWidth: 4 | ||||||
|  | ObjCBreakBeforeNestedBlockParam: false | ||||||
|  | SortIncludes: false | ||||||
|  | TabWidth: 4 | ||||||
|  | UseTab: Always | ||||||
|  | ... | ||||||
							
								
								
									
										2
									
								
								.git-blame-ignore-revs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.git-blame-ignore-revs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,2 @@ | |||||||
|  | # Add prettier to the project | ||||||
|  | 41024ddb7961b04a5688bbc997cb74de6fab4763 | ||||||
							
								
								
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								.gitignore
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,10 @@ | |||||||
|  | db.* | ||||||
|  | deps/ios_toolchain/ | ||||||
|  | deps/openssl/ | ||||||
|  | dist/ | ||||||
|  | .keys | ||||||
|  | **/node_modules | ||||||
|  | out | ||||||
|  | *.swo | ||||||
|  | *.swp | ||||||
|  | .zsign_cache/ | ||||||
							
								
								
									
										21
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								.gitmodules
									
									
									
									
										vendored
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | |||||||
|  | [submodule "deps/zlib"] | ||||||
|  | 	path = deps/zlib | ||||||
|  | 	url = https://github.com/madler/zlib.git | ||||||
|  | [submodule "deps/libsodium"] | ||||||
|  | 	path = deps/libsodium | ||||||
|  | 	url = https://github.com/jedisct1/libsodium.git | ||||||
|  | [submodule "deps/quickjs"] | ||||||
|  | 	path = deps/quickjs | ||||||
|  | 	url = https://github.com/bellard/quickjs.git | ||||||
|  | [submodule "deps/crypt_blowfish"] | ||||||
|  | 	path = deps/crypt_blowfish | ||||||
|  | 	url = https://github.com/openwall/crypt_blowfish.git | ||||||
|  | [submodule "deps/libbacktrace"] | ||||||
|  | 	path = deps/libbacktrace | ||||||
|  | 	url = https://github.com/ianlancetaylor/libbacktrace.git | ||||||
|  | [submodule "deps/libuv"] | ||||||
|  | 	path = deps/libuv | ||||||
|  | 	url = https://github.com/libuv/libuv.git | ||||||
|  | [submodule "deps/picohttpparser"] | ||||||
|  | 	path = deps/picohttpparser | ||||||
|  | 	url = https://github.com/h2o/picohttpparser.git | ||||||
							
								
								
									
										14
									
								
								.prettierignore
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								.prettierignore
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,14 @@ | |||||||
|  | node_modules | ||||||
|  | src | ||||||
|  | deps | ||||||
|  | .clang-format | ||||||
|  |  | ||||||
|  | # Minified files | ||||||
|  | **/*.min.css | ||||||
|  | **/*.min.js | ||||||
|  | **/leaflet.* | ||||||
|  | **/commonmark* | ||||||
|  | **/w3.css | ||||||
|  | apps/ssb/tribute.esm.js | ||||||
|  | apps/api/app.js | ||||||
|  | **/emojis.json | ||||||
							
								
								
									
										5
									
								
								.prettierrc.yaml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								.prettierrc.yaml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | trailingComma: 'es5' | ||||||
|  | useTabs: true | ||||||
|  | semi: true | ||||||
|  | singleQuote: true | ||||||
|  | bracketSpacing: false | ||||||
							
								
								
									
										37
									
								
								CONTRIBUTING.md
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										37
									
								
								CONTRIBUTING.md
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,37 @@ | |||||||
|  | # Contributing to Tilde Friends | ||||||
|  |  | ||||||
|  | Thank you for your interest in Tilde Friends. | ||||||
|  |  | ||||||
|  | Above all, Tilde Friends aims to be a fun, safe place to play. When that is at | ||||||
|  | odds with the course of development, we will work through it with respectful | ||||||
|  | communication. | ||||||
|  |  | ||||||
|  | ## How can I contribute? | ||||||
|  |  | ||||||
|  | The nature of Tilde Friends makes for a wide range of ways to contribute | ||||||
|  |  | ||||||
|  | - Just use it. Really, just kicking the tires will probably shake out issues | ||||||
|  |   in useful ways at this point. | ||||||
|  | - Report and comment on bugs: https://dev.tildefriends.net/issues. | ||||||
|  | - Make apps. You don't need my permission to make and share apps with Tilde | ||||||
|  |   Friends. I hope that an ecosystem of good apps grows outside of this | ||||||
|  |   repository. If you want to recreate better versions of the stock apps, just | ||||||
|  |   do it. If you make a better ssb app or whatever and drop me a line however | ||||||
|  |   is most convenient for you, I will probably take a look and consider | ||||||
|  |   replacing the stock one with it. | ||||||
|  | - Write about it. Docs in the git repository, blog posts, private messages to | ||||||
|  |   me with ideas...really there is no wrong answer. Just make some noise, and | ||||||
|  |   I'll do my best to incorporate or otherwise link your feedback and make the | ||||||
|  |   most of it. | ||||||
|  | - Write C code in the git repository. I'm really striving for it to be the | ||||||
|  |   case that other people don't really need to meddle in there, but if you can | ||||||
|  |   help out, I will gladly review your pull requests via | ||||||
|  |   https://dev.tildefriends.net/pulls. | ||||||
|  |  | ||||||
|  | ## Best practices | ||||||
|  |  | ||||||
|  | - The C code is formatted with clang-format. Run `make format`. | ||||||
|  | - The rest is formatted with prettier. Run `npm run prettier`. | ||||||
|  | - We strive to have code compile on all platforms with no warnings and run with | ||||||
|  |   no sanitizer issues. | ||||||
|  | - There are tests. Run `out/debug/tildefriends test`. | ||||||
							
								
								
									
										189
									
								
								GNUmakefile
									
									
									
									
									
								
							
							
						
						
									
										189
									
								
								GNUmakefile
									
									
									
									
									
								
							| @@ -3,9 +3,12 @@ | |||||||
| MAKEFLAGS += --warn-undefined-variables | MAKEFLAGS += --warn-undefined-variables | ||||||
| MAKEFLAGS += --no-builtin-rules | MAKEFLAGS += --no-builtin-rules | ||||||
|  |  | ||||||
| VERSION_CODE := 15 | VERSION_CODE := 19 | ||||||
| VERSION_NUMBER := 0.0.15 | VERSION_NUMBER := 0.0.19-wip | ||||||
| VERSION_NAME := Medium English breakfast tea. | VERSION_NAME := Don't let your loyalty become a burden. | ||||||
|  |  | ||||||
|  | SQLITE_URL := https://www.sqlite.org/2024/sqlite-amalgamation-3450300.zip | ||||||
|  | LIBUV_URL := https://dist.libuv.org/dist/v1.48.0/libuv-v1.48.0.tar.gz | ||||||
|  |  | ||||||
| PROJECT = tildefriends | PROJECT = tildefriends | ||||||
| BUILD_DIR ?= out | BUILD_DIR ?= out | ||||||
| @@ -14,6 +17,8 @@ UNAME_M := $(shell uname -m) | |||||||
|  |  | ||||||
| ANDROID_SDK ?= ~/Android/Sdk | ANDROID_SDK ?= ~/Android/Sdk | ||||||
|  |  | ||||||
|  | HAVE_WIN := 0 | ||||||
|  |  | ||||||
| ifeq ($(UNAME_S),Darwin) | ifeq ($(UNAME_S),Darwin) | ||||||
| BUILD_TYPES := macosdebug macosrelease iosdebug iosrelease iossimdebug iossimrelease | BUILD_TYPES := macosdebug macosrelease iosdebug iosrelease iossimdebug iossimrelease | ||||||
| else ifeq ($(UNAME_S),Linux) | else ifeq ($(UNAME_S),Linux) | ||||||
| @@ -36,7 +41,6 @@ LDFLAGS += \ | |||||||
| 	-lc++abi | 	-lc++abi | ||||||
| HAVE_ANDROID := 0 | HAVE_ANDROID := 0 | ||||||
| HAVE_LINUX_IOS := 0 | HAVE_LINUX_IOS := 0 | ||||||
| HAVE_WIN := 0 |  | ||||||
| else | else | ||||||
| $(error Unexpected host platform $(UNAME_S).) | $(error Unexpected host platform $(UNAME_S).) | ||||||
| endif | endif | ||||||
| @@ -47,16 +51,17 @@ CFLAGS += \ | |||||||
| 	-Wextra \ | 	-Wextra \ | ||||||
| 	-Wno-unused-parameter \ | 	-Wno-unused-parameter \ | ||||||
| 	-MMD \ | 	-MMD \ | ||||||
|  | 	-MP \ | ||||||
| 	-ffunction-sections \ | 	-ffunction-sections \ | ||||||
| 	-fdata-sections \ | 	-fdata-sections \ | ||||||
| 	-fno-exceptions \ | 	-fno-exceptions \ | ||||||
| 	-g | 	-g | ||||||
|  |  | ||||||
| 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_MIN_SDK_VERSION := 24 | ||||||
| ANDROID_TARGET_SDK_VERSION := 34 | ANDROID_TARGET_SDK_VERSION := 34 | ||||||
|  | ANDROID_BUILD_TOOLS := $(ANDROID_SDK)/build-tools/34.0.0 | ||||||
|  | ANDROID_PLATFORM := $(ANDROID_SDK)/platforms/android-$(ANDROID_TARGET_SDK_VERSION) | ||||||
|  | ANDROID_NDK ?= $(ANDROID_SDK)/ndk/26.2.11394342 | ||||||
|  |  | ||||||
| ANDROID_ARMV7A_TARGETS := \ | ANDROID_ARMV7A_TARGETS := \ | ||||||
| 	out/androiddebug-armv7a/tildefriends \ | 	out/androiddebug-armv7a/tildefriends \ | ||||||
| @@ -208,14 +213,14 @@ $(IOSSIM_TARGETS): LDFLAGS += -Ldeps/openssl/ios/iossimulator-xcrun/usr/local/li | |||||||
|  |  | ||||||
| ifeq ($(UNAME_M),x86_64) | ifeq ($(UNAME_M),x86_64) | ||||||
| ifneq ($(UNAME_S),Haiku) | ifneq ($(UNAME_S),Haiku) | ||||||
| debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common | out/debug/tildefriends: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common | ||||||
| debug: LDFLAGS += -fsanitize=address -fsanitize=undefined | out/debug/tildefriends: LDFLAGS += -fsanitize=address -fsanitize=undefined | ||||||
| endif | endif | ||||||
| endif | endif | ||||||
|  |  | ||||||
| ifeq ($(UNAME_M),aarch64) | ifeq ($(UNAME_M),aarch64) | ||||||
| debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common | out/debug/tildefriends: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common | ||||||
| debug: LDFLAGS += -fsanitize=address -fsanitize=undefined | out/debug/tildefriends: LDFLAGS += -fsanitize=address -fsanitize=undefined | ||||||
| endif | endif | ||||||
|  |  | ||||||
| get_objs = \ | get_objs = \ | ||||||
| @@ -244,7 +249,6 @@ $(APP_OBJS): CFLAGS += \ | |||||||
| 	-Ideps/quickjs \ | 	-Ideps/quickjs \ | ||||||
| 	-Ideps/sqlite \ | 	-Ideps/sqlite \ | ||||||
| 	-Ideps/valgrind \ | 	-Ideps/valgrind \ | ||||||
| 	-Ideps/xopt \ |  | ||||||
| 	-Wdouble-promotion \ | 	-Wdouble-promotion \ | ||||||
| 	-Werror | 	-Werror | ||||||
| ifeq ($(UNAME_M),x86_64) | ifeq ($(UNAME_M),x86_64) | ||||||
| @@ -496,18 +500,6 @@ $(SQLITE_OBJS): CFLAGS += \ | |||||||
| 	-Wno-unused-function \ | 	-Wno-unused-function \ | ||||||
| 	-Wno-unused-variable | 	-Wno-unused-variable | ||||||
|  |  | ||||||
| XOPT_SOURCES := deps/xopt/xopt.c |  | ||||||
| XOPT_OBJS := $(call get_objs,XOPT_SOURCES) |  | ||||||
| $(filter $(BUILD_DIR)/win%,$(XOPT_OBJS)): CFLAGS += \ |  | ||||||
| 	-DHAVE_SNPRINTF \ |  | ||||||
| 	-DHAVE_VSNPRINTF \ |  | ||||||
| 	-DHAVE_VASNPRINTF \ |  | ||||||
| 	-DHAVE_VASPRINTF \ |  | ||||||
| 	-Dvsnprintf=rpl_vsnprintf |  | ||||||
| $(XOPT_OBJS): CFLAGS += \ |  | ||||||
| 	-Wno-implicit-const-int-float-conversion \ |  | ||||||
| 	-Wno-pointer-to-int-cast |  | ||||||
|  |  | ||||||
| QUICKJS_SOURCES := \ | QUICKJS_SOURCES := \ | ||||||
| 	deps/quickjs/cutils.c \ | 	deps/quickjs/cutils.c \ | ||||||
| 	deps/quickjs/libbf.c \ | 	deps/quickjs/libbf.c \ | ||||||
| @@ -587,7 +579,7 @@ $(MINIUNZIP_OBJS): CFLAGS += \ | |||||||
| LDFLAGS += \ | LDFLAGS += \ | ||||||
| 	-pthread \ | 	-pthread \ | ||||||
| 	-lm | 	-lm | ||||||
| debug release $(MACOS_TARGETS) $(IOS_TARGETS) $(IOSSIM_TARGETS): LDFLAGS += \ | $(LINUX_TARGETS) $(MACOS_TARGETS) $(IOS_TARGETS) $(IOSSIM_TARGETS): LDFLAGS += \ | ||||||
| 	-lssl \ | 	-lssl \ | ||||||
| 	-lcrypto | 	-lcrypto | ||||||
| ifneq ($(UNAME_S),Haiku) | ifneq ($(UNAME_S),Haiku) | ||||||
| @@ -636,8 +628,7 @@ ALL_APP_OBJS := \ | |||||||
| 	$(QUICKJS_OBJS) \ | 	$(QUICKJS_OBJS) \ | ||||||
| 	$(SODIUM_OBJS) \ | 	$(SODIUM_OBJS) \ | ||||||
| 	$(SQLITE_OBJS) \ | 	$(SQLITE_OBJS) \ | ||||||
| 	$(UV_OBJS) \ | 	$(UV_OBJS) | ||||||
| 	$(XOPT_OBJS) |  | ||||||
|  |  | ||||||
| DEPS = $(ALL_APP_OBJS:.o=.d) | DEPS = $(ALL_APP_OBJS:.o=.d) | ||||||
| -include $(DEPS) | -include $(DEPS) | ||||||
| @@ -647,34 +638,34 @@ $(1): $(BUILD_DIR)/$(1)/$(PROJECT)$(if $(filter win%,$(1)),.exe) | |||||||
| .PHONY: $(1) | .PHONY: $(1) | ||||||
|  |  | ||||||
| $(BUILD_DIR)/$(1)/$(PROJECT)$(if $(filter win%,$(1)),.exe): $(filter $(BUILD_DIR)/$(1)/%,$(ALL_APP_OBJS)) | $(BUILD_DIR)/$(1)/$(PROJECT)$(if $(filter win%,$(1)),.exe): $(filter $(BUILD_DIR)/$(1)/%,$(ALL_APP_OBJS)) | ||||||
| 	@echo [link] $$@ | 	@echo "[link] $$@" | ||||||
| 	@$$(CC) -o $$@ $$^ $$(LDFLAGS) | 	@$$(CC) -o $$@ $$^ $$(LDFLAGS) | ||||||
|  |  | ||||||
| $(BUILD_DIR)/$(1)/%.o: %.c | $(BUILD_DIR)/$(1)/%.o: %.c | ||||||
| 	@mkdir -p $$(dir $$@) | 	@mkdir -p $$(dir $$@) | ||||||
| 	@echo [c] $$@ | 	@echo "[c] $$@" | ||||||
| 	@$$(CC) $$(CFLAGS) -c $$< -o $$@ | 	@$$(CC) $$(CFLAGS) -c $$< -o $$@ | ||||||
|  |  | ||||||
| $(BUILD_DIR)/$(1)/%.o: %.m | $(BUILD_DIR)/$(1)/%.o: %.m | ||||||
| 	@mkdir -p $$(dir $$@) | 	@mkdir -p $$(dir $$@) | ||||||
| 	@echo [m] $$@ | 	@echo "[m] $$@" | ||||||
| 	@$$(CC) $$(CFLAGS) -c $$< -o $$@ | 	@$$(CC) $$(CFLAGS) -c $$< -o $$@ | ||||||
|  |  | ||||||
| $(BUILD_DIR)/$(1)/%.o: %.S | $(BUILD_DIR)/$(1)/%.o: %.S | ||||||
| 	@mkdir -p $$(dir $$@) | 	@mkdir -p $$(dir $$@) | ||||||
| 	@echo [as] $$@ | 	@echo "[as] $$@" | ||||||
| 	@$$(AS) -c $$< -o $$@ | 	@$$(AS) -c $$< -o $$@ | ||||||
| endef | endef | ||||||
|  |  | ||||||
| $(foreach build_type,$(BUILD_TYPES),$(eval $(call build_rules,$(build_type)))) | $(foreach build_type,$(BUILD_TYPES),$(eval $(call build_rules,$(build_type)))) | ||||||
|  |  | ||||||
| src/version.h : $(firstword $(MAKEFILE_LIST)) | src/version.h : $(firstword $(MAKEFILE_LIST)) | ||||||
| 	@echo [version] $@ | 	@echo "[version] $@" | ||||||
| 	@echo "#define VERSION_NUMBER \"$(VERSION_NUMBER)\"" > $@ | 	@echo "#define VERSION_NUMBER \"$(VERSION_NUMBER)\"" > $@ | ||||||
| 	@echo "#define VERSION_NAME \"$(VERSION_NAME)\"" >> $@ | 	@echo "#define VERSION_NAME \"$(VERSION_NAME)\"" >> $@ | ||||||
|  |  | ||||||
| src/android/AndroidManifest.xml : $(firstword $(MAKEFILE_LIST)) | src/android/AndroidManifest.xml : $(firstword $(MAKEFILE_LIST)) | ||||||
| 	@echo [android_version] $@ | 	@echo "[android_version] $@" | ||||||
| 	@sed -i \ | 	@sed -i \ | ||||||
| 		-e 's/versionCode=".*"/versionCode="$(VERSION_CODE)"/' \ | 		-e 's/versionCode=".*"/versionCode="$(VERSION_CODE)"/' \ | ||||||
| 		-e 's/versionName=".*"/versionName="$(VERSION_NUMBER)"/' \ | 		-e 's/versionName=".*"/versionName="$(VERSION_NUMBER)"/' \ | ||||||
| @@ -685,12 +676,12 @@ src/android/AndroidManifest.xml : $(firstword $(MAKEFILE_LIST)) | |||||||
| # Android support. | # Android support. | ||||||
| out/res/layout_activity_main.xml.flat: src/android/res/layout/activity_main.xml | out/res/layout_activity_main.xml.flat: src/android/res/layout/activity_main.xml | ||||||
| 	@mkdir -p $(dir $@) | 	@mkdir -p $(dir $@) | ||||||
| 	@echo [aapt2] $@ | 	@echo "[aapt2] $@" | ||||||
| 	@$(ANDROID_BUILD_TOOLS)/aapt2 compile -o out/res/ src/android/res/layout/activity_main.xml | 	@$(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 | out/res/drawable_icon.xml.flat: src/android/res/drawable/icon.xml | ||||||
| 	@mkdir -p $(dir $@) | 	@mkdir -p $(dir $@) | ||||||
| 	@echo [aapt2] $@ | 	@echo "[aapt2] $@" | ||||||
| 	@$(ANDROID_BUILD_TOOLS)/aapt2 compile -o out/res/ src/android/res/drawable/icon.xml | 	@$(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 | 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 | ||||||
| @@ -701,21 +692,22 @@ JAVA_FILES := out/gen/com/unprompted/tildefriends/R.java $(wildcard src/android/ | |||||||
| CLASS_FILES := $(foreach src,$(JAVA_FILES),out/classes/com/unprompted/tildefriends/$(notdir $(src:.java=.class))) | CLASS_FILES := $(foreach src,$(JAVA_FILES),out/classes/com/unprompted/tildefriends/$(notdir $(src:.java=.class))) | ||||||
|  |  | ||||||
| $(CLASS_FILES) &: $(JAVA_FILES) | $(CLASS_FILES) &: $(JAVA_FILES) | ||||||
| 	@echo [javac] $(CLASS_FILES) | 	@echo "[javac] $(CLASS_FILES)" | ||||||
| 	@javac --release 8 -Xlint:deprecation -classpath $(ANDROID_PLATFORM)/android.jar -d out/classes $(JAVA_FILES) | 	@javac --release 8 -encoding UTF-8 -Xlint:deprecation -XDuseUnsharedTable=true -classpath $(ANDROID_PLATFORM)/android.jar:$(ANDROID_BUILD_TOOLS)/core-lambda-stubs.jar -d out/classes $(JAVA_FILES) | ||||||
|  |  | ||||||
| out/apk/classes.dex: $(CLASS_FILES) | out/apk/classes.dex: $(CLASS_FILES) | ||||||
| 	@mkdir -p $(dir $@) | 	@mkdir -p $(dir $@) | ||||||
| 	@echo [d8] $@ | 	@echo "[d8] $@" | ||||||
| 	@$(ANDROID_BUILD_TOOLS)/d8 --$(BUILD_TYPE) --lib $(ANDROID_PLATFORM)/android.jar --output $(dir $@) out/classes/com/unprompted/tildefriends/*.class | 	@$(ANDROID_BUILD_TOOLS)/d8 --$(BUILD_TYPE) --lib $(ANDROID_PLATFORM)/android.jar --output $(dir $@) out/classes/com/unprompted/tildefriends/*.class | ||||||
|  |  | ||||||
| PACKAGE_DIRS := \ | PACKAGE_DIRS := \ | ||||||
| 	apps/ \ | 	apps/ \ | ||||||
| 	core/ \ | 	core/ \ | ||||||
| 	deps/codemirror/ \ | 	deps/codemirror/ \ | ||||||
|  | 	deps/prettier/ \ | ||||||
| 	deps/lit/ | 	deps/lit/ | ||||||
|  |  | ||||||
| RAW_FILES := $(filter-out apps/blog% apps/gg% apps/issues% apps/welcome% apps/journal% %.map, $(shell find $(PACKAGE_DIRS) -type f)) | RAW_FILES := $(filter-out apps/blog% apps/issues% apps/welcome% apps/journal% %.map, $(shell find $(PACKAGE_DIRS) -type f)) | ||||||
|  |  | ||||||
| out/apk/TildeFriends-arm-debug.unsigned.apk: BUILD_TYPE := debug | out/apk/TildeFriends-arm-debug.unsigned.apk: BUILD_TYPE := debug | ||||||
| out/apk/TildeFriends-arm-release.unsigned.apk: BUILD_TYPE := release | out/apk/TildeFriends-arm-release.unsigned.apk: BUILD_TYPE := release | ||||||
| @@ -729,38 +721,45 @@ out/apk/TildeFriends-x86-release.unsigned.apk: out/apk/classes.dex out/androidre | |||||||
|  |  | ||||||
| out/apk/TildeFriends-arm-%.unsigned.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/ | 	@mkdir -p $(dir $@) out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/ out/apk-arm-$(BUILD_TYPE)/lib/armeabi-v7a/ | ||||||
| 	@echo [aapt] $@ | 	@echo "[aapt] $@" | ||||||
| 	@cp out/android$(BUILD_TYPE)/tildefriends out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/tildefriends.so | 	@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 | 	@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/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 | 	@$(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/res.apk $@.zip | ||||||
| 	@cp out/apk/classes.dex out/apk-arm-$(BUILD_TYPE)/ | 	@cp out/apk/classes.dex out/apk-arm-$(BUILD_TYPE)/ | ||||||
| 	@cd out/apk-arm-$(BUILD_TYPE) && zip -u ../../$@ -q -9 -r . && cd ../../ | 	@cd out/apk-arm-$(BUILD_TYPE) && zip -u ../../$@.zip -q -9 -r . && cd ../../ | ||||||
| 	@zip -u $@ -q -9 $(RAW_FILES) | 	@zip -u $@.zip -q -9 $(RAW_FILES) | ||||||
|  | 	@$(ANDROID_BUILD_TOOLS)/zipalign -f 4 $@.zip $@ | ||||||
|  |  | ||||||
| out/apk/TildeFriends-x86-%.unsigned.apk: | out/apk/TildeFriends-x86-%.unsigned.apk: | ||||||
| 	@mkdir -p $(dir $@) out/apk-x86-$(BUILD_TYPE)/lib/x86_64/ out/apk-x86-$(BUILD_TYPE)/lib/x86/ | 	@mkdir -p $(dir $@) out/apk-x86-$(BUILD_TYPE)/lib/x86_64/ out/apk-x86-$(BUILD_TYPE)/lib/x86/ | ||||||
| 	@echo [aapt] $@ | 	@echo "[aapt] $@" | ||||||
| 	@cp out/android$(BUILD_TYPE)-x86_64/tildefriends out/apk-x86-$(BUILD_TYPE)/lib/x86_64/tildefriends.so | 	@cp out/android$(BUILD_TYPE)-x86_64/tildefriends out/apk-x86-$(BUILD_TYPE)/lib/x86_64/tildefriends.so | ||||||
| 	@cp out/android$(BUILD_TYPE)-x86/tildefriends out/apk-x86-$(BUILD_TYPE)/lib/x86/tildefriends.so | 	@cp out/android$(BUILD_TYPE)-x86/tildefriends out/apk-x86-$(BUILD_TYPE)/lib/x86/tildefriends.so | ||||||
| 	@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-x86-$(BUILD_TYPE)/lib/x86_64/tildefriends.so | 	@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-x86-$(BUILD_TYPE)/lib/x86_64/tildefriends.so | ||||||
| 	@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-x86-$(BUILD_TYPE)/lib/x86/tildefriends.so | 	@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-x86-$(BUILD_TYPE)/lib/x86/tildefriends.so | ||||||
| 	@cp out/apk/res.apk $@ | 	@cp out/apk/res.apk $@.zip | ||||||
| 	@cp out/apk/classes.dex out/apk-x86-$(BUILD_TYPE)/ | 	@cp out/apk/classes.dex out/apk-x86-$(BUILD_TYPE)/ | ||||||
| 	@cd out/apk-x86-$(BUILD_TYPE) && zip -u ../../$@ -q -9 -r . && cd ../../ | 	@cd out/apk-x86-$(BUILD_TYPE) && zip -u ../../$@.zip -q -9 -r . && cd ../../ | ||||||
| 	@zip -u $@ -q -9 $(RAW_FILES) | 	@zip -u $@.zip -q -9 $(RAW_FILES) | ||||||
|  | 	@$(ANDROID_BUILD_TOOLS)/zipalign -f 4 $@.zip $@ | ||||||
|  |  | ||||||
| out/%.apk: out/apk/%.unsigned.apk | out/%.apk: out/apk/%.unsigned.apk | ||||||
| 	@echo [apksigner] $(notdir $@) | 	@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 $@ $< | 	@$(ANDROID_BUILD_TOOLS)/apksigner sign --ks .keys/android.jks --ks-key-alias androidKey --ks-pass pass:android --key-pass pass:android --min-sdk-version $(ANDROID_MIN_SDK_VERSION) --out $@ $< | ||||||
|  |  | ||||||
| release-apk: out/TildeFriends-arm-release.apk out/TildeFriends-x86-release.apk | out/%.zopfli.apk: out/%.apk | ||||||
|  | 	@echo "[zopfli] $(notdir $@)" | ||||||
|  | 	$(ANDROID_BUILD_TOOLS)/zipalign -f -z 4 $< $@.zopfli | ||||||
|  | 	@$(ANDROID_BUILD_TOOLS)/apksigner sign --ks .keys/android.jks --ks-key-alias androidKey --ks-pass pass:android --key-pass pass:android --min-sdk-version $(ANDROID_MIN_SDK_VERSION) --out $@ $@.zopfli | ||||||
|  |  | ||||||
|  | release-apk: out/TildeFriends-arm-release.zopfli.apk out/TildeFriends-x86-release.zopfli.apk | ||||||
| .PHONY: release-apk | .PHONY: release-apk | ||||||
|  |  | ||||||
| releaseapkgo: out/TildeFriends-arm-release.apk | releaseapkgo: out/TildeFriends-arm-release.apk | ||||||
| 	@adb install -r $< | 	@adb install -r $< | ||||||
| 	@adb shell am start com.unprompted.tildefriends/.MainActivity | 	@adb shell am start com.unprompted.tildefriends/.TildeFriendsActivity | ||||||
| .PHONY: releaseapkgo | .PHONY: releaseapkgo | ||||||
|  |  | ||||||
| # iOS Support | # iOS Support | ||||||
| @@ -771,10 +770,10 @@ out/%.app/tildefriends.png: src/ios/tildefriends.png | |||||||
| 	@mkdir -p $(dir $@) | 	@mkdir -p $(dir $@) | ||||||
| 	@cp -v $< $@ | 	@cp -v $< $@ | ||||||
|  |  | ||||||
| out/%/data.zip: $(RAW_FILES) | out/data.zip: $(RAW_FILES) | ||||||
| 	@zip -u $@ -q -9 $(RAW_FILES) | 	@zip -u $@ -q -9 $(RAW_FILES) | ||||||
|  |  | ||||||
| out/tildefriends-%.app/tildefriends: out/%/tildefriends out/tildefriends-%.app/Info.plist out/tildefriends-%.app/tildefriends.png out/tildefriends-%.app/data.zip | out/tildefriends-%.app/tildefriends: out/%/tildefriends out/tildefriends-%.app/Info.plist out/tildefriends-%.app/tildefriends.png out/data.zip | ||||||
| 	@mkdir -p $(dir $@) | 	@mkdir -p $(dir $@) | ||||||
| 	@cp -v $< $@ | 	@cp -v $< $@ | ||||||
| ifeq ($(HAVE_LINUX_IOS),1) | ifeq ($(HAVE_LINUX_IOS),1) | ||||||
| @@ -782,13 +781,23 @@ ifeq ($(HAVE_LINUX_IOS),1) | |||||||
| endif | endif | ||||||
| .SECONDARY: | .SECONDARY: | ||||||
| out/tildefriends-%.ipa: out/tildefriends-ios%.app/tildefriends | out/tildefriends-%.ipa: out/tildefriends-ios%.app/tildefriends | ||||||
| 	@echo [ipa] $@ | 	@echo "[ipa] $@" | ||||||
| 	@rm -rf $@.tmp $@ | 	@rm -rf $@.tmp $@ | ||||||
| 	@mkdir -p $@.tmp/Payload/tildefriends.app/ | 	@mkdir -p $@.tmp/Payload/tildefriends.app/ | ||||||
| 	@cp -R $(dir $<)/* $@.tmp/Payload/tildefriends.app/ | 	@cp -R $(dir $<)/* $@.tmp/Payload/tildefriends.app/ | ||||||
| 	@cd $@.tmp/ && zip -u ../../$@ -q -9 -r ./ | 	@cd $@.tmp/ && zip -u ../../$@ -q -9 -r ./ | ||||||
| 	@rm -rf $@.tmp/ | 	@rm -rf $@.tmp/ | ||||||
|  |  | ||||||
|  |  | ||||||
|  | out/%/tildefriends.standalone: out/%/tildefriends out/data.zip | ||||||
|  | 	@echo "[standalone] $@" | ||||||
|  | 	@cat $< out/data.zip > $@ | ||||||
|  | 	@chmod +x $@ | ||||||
|  | out/%/tildefriends.standalone.exe: out/%/tildefriends.exe out/data.zip | ||||||
|  | 	@echo "[standalone] $@" | ||||||
|  | 	@cat $< out/data.zip > $@ | ||||||
|  | 	@chmod +x $@ | ||||||
|  |  | ||||||
| iossimdebug-app: out/tildefriends-iossimdebug.app/tildefriends | iossimdebug-app: out/tildefriends-iossimdebug.app/tildefriends | ||||||
| iossimrelease-app: out/tildefriends-iossimrelease.app/tildefriends | iossimrelease-app: out/tildefriends-iossimrelease.app/tildefriends | ||||||
| iosdebug-app: out/tildefriends-iosdebug.app/tildefriends | iosdebug-app: out/tildefriends-iosdebug.app/tildefriends | ||||||
| @@ -810,18 +819,51 @@ apklog: | |||||||
| 	@adb logcat *:S tildefriends | 	@adb logcat *:S tildefriends | ||||||
| .PHONY: apklog | .PHONY: apklog | ||||||
|  |  | ||||||
|  | fetchdeps: | ||||||
|  | 	@echo "[fetch] libuv" | ||||||
|  | 	@test -f out/deps/libuv.tar.gz && test "$$(cat out/deps/libuv.txt 2>/dev/null)" = $(LIBUV_URL) || (mkdir -p out/deps/ && curl -q $(LIBUV_URL) -o out/deps/libuv.tar.gz) | ||||||
|  | 	@test -d deps/libuv/ && test "$$(cat out/deps/libuv.txt 2>/dev/null)" = $(LIBUV_URL) || (rm -rf deps/libuv/ && mkdir -p deps/libuv/ && tar -C deps/libuv/ -m --strip=1 -xf out/deps/libuv.tar.gz) | ||||||
|  | 	@echo -n $(LIBUV_URL) > out/deps/libuv.txt | ||||||
|  | 	@echo "[fetch] sqlite" | ||||||
|  | 	@test -f out/deps/sqlite.zip && test "$$(cat out/deps/sqlite.txt 2>/dev/null)" = $(SQLITE_URL) || (mkdir -p out/deps/ && curl -q $(SQLITE_URL) -o out/deps/sqlite.zip) | ||||||
|  | 	@test -d deps/sqlite/ && test "$$(cat out/deps/sqlite.txt 2>/dev/null)" = $(SQLITE_URL) || (mkdir -p deps/sqlite/ && unzip -qDjo -d deps/sqlite/ out/deps/sqlite.zip) | ||||||
|  | 	@echo -n $(SQLITE_URL) > out/deps/sqlite.txt | ||||||
|  | 	@echo "[fetch] prettier" | ||||||
|  | 	@test -f deps/prettier/standalone.mjs || curl -q --create-dirs -O --output-dir deps/prettier/ https://cdn.jsdelivr.net/npm/prettier@3.2.5/standalone.mjs | ||||||
|  | 	@test -f deps/prettier/html.mjs || curl -q --create-dirs -O --output-dir deps/prettier/ https://cdn.jsdelivr.net/npm/prettier@3.2.5/plugins/html.mjs | ||||||
|  | 	@test -f deps/prettier/babel.mjs || curl -q --create-dirs -O --output-dir deps/prettier/ https://cdn.jsdelivr.net/npm/prettier@3.2.5/plugins/babel.mjs | ||||||
|  | 	@test -f deps/prettier/estree.mjs || curl -q --create-dirs -O --output-dir deps/prettier/ https://cdn.jsdelivr.net/npm/prettier@3.2.5/plugins/estree.mjs | ||||||
|  | .PHONY: fetchdeps | ||||||
|  |  | ||||||
|  | ANDROID_DEPS := deps/openssl/android/arm64-v8a/usr/local/lib/libssl.a | ||||||
|  | $(ANDROID_DEPS): | ||||||
|  | 	+@tools/ssl-android | ||||||
|  | $(filter $(BUILD_DIR)/android%,$(APP_OBJS)): | $(ANDROID_DEPS) | ||||||
|  |  | ||||||
|  | ifeq ($(HAVE_WIN),1) | ||||||
|  | WINDOWS_DEPS := deps/openssl/mingw64/usr/local/lib/libssl.a | ||||||
|  | $(WINDOWS_DEPS): | ||||||
|  | 	+@tools/ssl-mingw64 | ||||||
|  | $(filter $(BUILD_DIR)/win%,$(APP_OBJS)): | $(WINDOWS_DEPS) | ||||||
|  | endif | ||||||
|  |  | ||||||
|  | ifeq ($(UNAME_S),Darwin) | ||||||
|  | IOS_DEPS := deps/openssl/ios/ios64-xcrun/usr/local/lib/libssl.a | ||||||
|  | $(IOS_DEPS): | ||||||
|  | 	+@tools/ssl-ios | ||||||
|  | $(filter $(BUILD_DIR)/ios%,$(APP_OBJS)): | $(IOS_DEPS) | ||||||
|  | endif | ||||||
|  |  | ||||||
| clean: | clean: | ||||||
| 	rm -rf $(BUILD_DIR) | 	rm -rf $(BUILD_DIR) | ||||||
| .PHONY: clean | .PHONY: clean | ||||||
|  |  | ||||||
| dist: release-apk iosrelease-ipa | dist: release-apk iosrelease-ipa $(if $(HAVE_WIN), out/winrelease/tildefriends.standalone.exe) | ||||||
| 	@echo "[export] $$(svn info --show-item url)" | 	@echo [archive] dist/tildefriends-$(VERSION_NUMBER).tar.xz | ||||||
| 	@rm -rf tildefriends-$(VERSION_NUMBER) | 	@rm -rf out/tildefriends-$(VERSION_NUMBER) | ||||||
| 	@svn export -q . tildefriends-$(VERSION_NUMBER) | 	@mkdir -p dist/ out/tildefriends-$(VERSION_NUMBER) | ||||||
| 	@echo "tildefriends-$(VERSION_NUMBER): $(VERSION_NAME)" > tildefriends-$(VERSION_NUMBER)/VERSION | 	@git ls-files --recurse-submodules | tar -c -T- | tar -x -C out/tildefriends-$(VERSION_NUMBER) | ||||||
| 	@echo "[tar] tildefriends-$(VERSION_NUMBER).tar.xz" |  | ||||||
| 	@tar \ | 	@tar \ | ||||||
| 		--exclude=apps/gg* \ |  | ||||||
| 		--exclude=apps/welcome* \ | 		--exclude=apps/welcome* \ | ||||||
| 		--exclude=deps/libbacktrace/Isaac.Newton-Opticks.txt \ | 		--exclude=deps/libbacktrace/Isaac.Newton-Opticks.txt \ | ||||||
| 		--exclude=deps/libsodium/builds/msvc/vs* \ | 		--exclude=deps/libsodium/builds/msvc/vs* \ | ||||||
| @@ -836,14 +878,17 @@ dist: release-apk iosrelease-ipa | |||||||
| 		--exclude=deps/sqlite/shell.c \ | 		--exclude=deps/sqlite/shell.c \ | ||||||
| 		--exclude=deps/zlib/contrib/vstudio \ | 		--exclude=deps/zlib/contrib/vstudio \ | ||||||
| 		--exclude=deps/zlib/doc \ | 		--exclude=deps/zlib/doc \ | ||||||
| 		-caf tildefriends-$(VERSION_NUMBER).tar.xz tildefriends-$(VERSION_NUMBER) | 		-caf dist/tildefriends-$(VERSION_NUMBER).tar.xz \ | ||||||
| 	@rm -rf tildefriends-$(VERSION_NUMBER) | 		-C out/ \ | ||||||
|  | 		tildefriends-$(VERSION_NUMBER) | ||||||
| 	@echo "[cp] TildeFriends-x86-$(VERSION_NUMBER).apk" | 	@echo "[cp] TildeFriends-x86-$(VERSION_NUMBER).apk" | ||||||
| 	@cp out/TildeFriends-x86-release.apk TildeFriends-x86-$(VERSION_NUMBER).apk | 	@cp out/TildeFriends-x86-release.zopfli.apk dist/TildeFriends-x86-$(VERSION_NUMBER).apk | ||||||
| 	@echo "[cp] TildeFriends-arm-$(VERSION_NUMBER).apk" | 	@echo "[cp] TildeFriends-arm-$(VERSION_NUMBER).apk" | ||||||
| 	@cp out/TildeFriends-arm-release.apk TildeFriends-arm-$(VERSION_NUMBER).apk | 	@cp out/TildeFriends-arm-release.zopfli.apk dist/TildeFriends-arm-$(VERSION_NUMBER).apk | ||||||
| 	@echo "[cp] TildeFriends-$(VERSION_NUMBER).ipa" | 	@echo "[cp] TildeFriends-$(VERSION_NUMBER).ipa" | ||||||
| 	@cp out/tildefriends-release.ipa TildeFriends-$(VERSION_NUMBER).ipa | 	@cp out/tildefriends-release.ipa dist/TildeFriends-$(VERSION_NUMBER).ipa | ||||||
|  | 	@test $(HAVE_WIN) && echo "[cp] tildefriends-$(VERSION_NUMBER).exe" | ||||||
|  | 	@test $(HAVE_WIN) && cp out/winrelease/tildefriends.standalone.exe dist/tildefriends-$(VERSION_NUMBER).exe | ||||||
| .PHONY: dist | .PHONY: dist | ||||||
|  |  | ||||||
| dist-test: dist | dist-test: dist | ||||||
| @@ -852,3 +897,15 @@ dist-test: dist | |||||||
| 	@docker build tildefriends-$(VERSION_NUMBER)/ | 	@docker build tildefriends-$(VERSION_NUMBER)/ | ||||||
| 	@rm -rf tildefriends-$(VERSION_NUMBER) | 	@rm -rf tildefriends-$(VERSION_NUMBER) | ||||||
| .PHONY: dist-test | .PHONY: dist-test | ||||||
|  |  | ||||||
|  | format: | ||||||
|  | 	@clang-format -i $(wildcard src/*.c src/*.h src/*.m) | ||||||
|  | .PHONY: format | ||||||
|  |  | ||||||
|  | prettier: | ||||||
|  | 	@npm run prettier | ||||||
|  | .PHONY: prettier | ||||||
|  |  | ||||||
|  | docs: | ||||||
|  | 	@doxygen | ||||||
|  | .PHONY: docs | ||||||
|   | |||||||
							
								
								
									
										18
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										18
									
								
								README.md
									
									
									
									
									
								
							| @@ -1,16 +1,24 @@ | |||||||
| # Tilde Friends | # Tilde Friends | ||||||
|  |  | ||||||
| Tilde Friends is a tool for making and sharing. | Tilde Friends is a tool for making and sharing. | ||||||
|  |  | ||||||
|  | A public instance lives at https://www.tildefriends.net/. | ||||||
|  |  | ||||||
| It is both a peer-to-peer social network client, participating in Secure | It is both a peer-to-peer social network client, participating in Secure | ||||||
| Scuttlebutt, as well as a platform for writing and running web applications. | Scuttlebutt, as well as a platform for writing and running web applications. | ||||||
|  |  | ||||||
| ## Goals | ## Goals | ||||||
|  |  | ||||||
| 1. Make it easy and fun to run all sorts of web applications. | 1. Make it easy and fun to run all sorts of web applications. | ||||||
| 2. Provide security that is easy to understand and protects your data. | 2. Provide security that is easy to understand and protects your data. | ||||||
| 3. Make creating and sharing web applications accessible to anyone with a | 3. Make creating and sharing web applications accessible to anyone with a | ||||||
|    browser. |    browser. | ||||||
|  |  | ||||||
| ## Building | ## Building | ||||||
|  |  | ||||||
|  | Builds on Linux (x86_64 and aarch64), MacOS, OpenBSD, and Haiku. Builds for | ||||||
|  | all of those host platforms plus mingw64, iOS, and android. | ||||||
|  |  | ||||||
| 1. Requires openssl (`libssl-dev`, in debian-speak). All other dependencies | 1. Requires openssl (`libssl-dev`, in debian-speak). All other dependencies | ||||||
|    are kept up to date in the tree. |    are kept up to date in the tree. | ||||||
| 2. To build, run `make debug` or `make release`. An executable will be | 2. To build, run `make debug` or `make release`. An executable will be | ||||||
| @@ -19,19 +27,23 @@ Scuttlebutt, as well as a platform for writing and running web applications. | |||||||
|    the right dependencies in the right places. `make windebug winrelease |    the right dependencies in the right places. `make windebug winrelease | ||||||
| iosdebug-ipa iosrelease-ipa release-apk`. | iosdebug-ipa iosrelease-ipa release-apk`. | ||||||
| 4. To build in docker, `docker build .`. | 4. To build in docker, `docker build .`. | ||||||
|  | 5. `make format` will normalize formatting to the coding standard. | ||||||
|  |  | ||||||
| ## Running | ## Running | ||||||
|  |  | ||||||
| By default, running the built `tildefriends` executable will start a web server | By default, running the built `tildefriends` executable will start a web server | ||||||
| at <http://localhost:12345/>. `tildefriends -h` lists further options. | at <http://localhost:12345/>. `tildefriends -h` lists further options. | ||||||
|  |  | ||||||
| The first user to create an account and log in will be granted administrative | The first user to create an account and log in will be granted administrative | ||||||
| privileges. Further administration can be done at | privileges. Further administration can be done at | ||||||
| <http://localhost:12345/~core/admin/`>. | <http://localhost:12345/~core/admin/>. | ||||||
|  |  | ||||||
| ## Documentation | ## Documentation | ||||||
| There are the very beginnings of developer documentation in `apps/docs/` |  | ||||||
| that can be read in-place or at <http://localhost:12345/~core/docs/>. | Docs are a work in progress: | ||||||
|  | <https://www.tildefriends.net/~cory/wiki/#test-wiki/tf-app-quick-reference>. | ||||||
|  |  | ||||||
| ## License | ## License | ||||||
|  |  | ||||||
| All code unless otherwise noted in is provided under the | All code unless otherwise noted in is provided under the | ||||||
| [MIT](https://opensource.org/licenses/MIT) license. | [MIT](https://opensource.org/licenses/MIT) license. | ||||||
|   | |||||||
| @@ -1,4 +1,5 @@ | |||||||
| { | { | ||||||
| 	"type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
|   "emoji": "🎛" | 	"emoji": "🎛", | ||||||
|  | 	"previous": "&vrpS/vE7n588iYv1p8HafDxHB+YDHTrtUbJiu9nGA9I=.sha256" | ||||||
| } | } | ||||||
| @@ -18,9 +18,13 @@ async function main() { | |||||||
| 		for (let user of await core.users()) { | 		for (let user of await core.users()) { | ||||||
| 			data.users[user] = await core.permissionsForUser(user); | 			data.users[user] = await core.permissionsForUser(user); | ||||||
| 		} | 		} | ||||||
| 		await app.setDocument(utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data))); | 		await app.setDocument( | ||||||
|  | 			utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data)) | ||||||
|  | 		); | ||||||
| 	} catch { | 	} catch { | ||||||
| 		await app.setDocument('<span style="color: #f00">Only an administrator can modify these settings.</span>'); | 		await app.setDocument( | ||||||
|  | 			'<span style="color: #f00">Only an administrator can modify these settings.</span>' | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
| main(); | main(); | ||||||
| @@ -1,10 +1,40 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html style="width: 100%"> | <html style="width: 100%"> | ||||||
| 	<head> | 	<head> | ||||||
| 		<script>const g_data = $data;</script> | 		<script> | ||||||
|  | 			const g_data = $data; | ||||||
|  | 		</script> | ||||||
|  | 		<link rel="stylesheet" href="w3.css"></link> | ||||||
|  | 		<style> | ||||||
|  | 			/* 2018 Valiant Poppy */ | ||||||
|  | 			.w3-theme-l5 {color:#000 !important; background-color:#fbf3f3 !important} | ||||||
|  | 			.w3-theme-l4 {color:#000 !important; background-color:#f3d7d6 !important} | ||||||
|  | 			.w3-theme-l3 {color:#000 !important; background-color:#e6afae !important} | ||||||
|  | 			.w3-theme-l2 {color:#fff !important; background-color:#da8785 !important} | ||||||
|  | 			.w3-theme-l1 {color:#fff !important; background-color:#cd5f5d !important} | ||||||
|  | 			.w3-theme-d1 {color:#fff !important; background-color:#a93634 !important} | ||||||
|  | 			.w3-theme-d2 {color:#fff !important; background-color:#96302e !important} | ||||||
|  | 			.w3-theme-d3 {color:#fff !important; background-color:#832a28 !important} | ||||||
|  | 			.w3-theme-d4 {color:#fff !important; background-color:#702423 !important} | ||||||
|  | 			.w3-theme-d5 {color:#fff !important; background-color:#5e1e1d !important} | ||||||
|  |  | ||||||
|  | 			.w3-theme-light {color:#000 !important; background-color:#fbf3f3 !important} | ||||||
|  | 			.w3-theme-dark {color:#fff !important; background-color:#5e1e1d !important} | ||||||
|  | 			.w3-theme-action {color:#fff !important; background-color:#5e1e1d !important} | ||||||
|  |  | ||||||
|  | 			.w3-theme {color:#fff !important; background-color:#bd3d3a !important} | ||||||
|  | 			.w3-text-theme {color:#bd3d3a !important} | ||||||
|  | 			.w3-border-theme {border-color:#bd3d3a !important} | ||||||
|  |  | ||||||
|  | 			.w3-hover-theme:hover {color:#fff !important; background-color:#bd3d3a !important} | ||||||
|  | 			.w3-hover-text-theme:hover {color:#bd3d3a !important} | ||||||
|  | 			.w3-hover-border-theme:hover {border-color:#bd3d3a !important} | ||||||
|  | 		</style> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body style="color: #fff; width: 100%"> | 	<body class="w3-theme-l4"> | ||||||
|  | 		<header class="w3-row w3-padding w3-header w3-theme-l1"> | ||||||
| 			<h1>Tilde Friends Administration</h1> | 			<h1>Tilde Friends Administration</h1> | ||||||
|  | 		</header> | ||||||
| 	</body> | 	</body> | ||||||
| 	<script type="module" src="script.js"></script> | 	<script type="module" src="script.js"></script> | ||||||
| </html> | </html> | ||||||
| @@ -3,85 +3,85 @@ import * as tfrpc from '/static/tfrpc.js'; | |||||||
|  |  | ||||||
| function delete_user(user) { | function delete_user(user) { | ||||||
| 	if (confirm(`Are you sure you want to delete the user "${user}"?`)) { | 	if (confirm(`Are you sure you want to delete the user "${user}"?`)) { | ||||||
| 		tfrpc.rpc.delete_user(user).then(function() { | 		tfrpc.rpc | ||||||
|  | 			.delete_user(user) | ||||||
|  | 			.then(function () { | ||||||
| 				alert(`User "${user}" deleted successfully.`); | 				alert(`User "${user}" deleted successfully.`); | ||||||
| 		}).catch(function(error) { | 			}) | ||||||
| 			alert(`Failed to delete user "${user}": ${JSON.stringify(error, null, 2)}.`); | 			.catch(function (error) { | ||||||
|  | 				alert( | ||||||
|  | 					`Failed to delete user "${user}": ${JSON.stringify(error, null, 2)}.` | ||||||
|  | 				); | ||||||
| 			}); | 			}); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| function global_settings_set(key, value) { | function global_settings_set(key, value) { | ||||||
| 	tfrpc.rpc.global_settings_set(key, value).then(function() { | 	tfrpc.rpc | ||||||
|  | 		.global_settings_set(key, value) | ||||||
|  | 		.then(function () { | ||||||
| 			alert(`Set "${key}" to "${value}".`); | 			alert(`Set "${key}" to "${value}".`); | ||||||
| 	}).catch(function(error) { | 		}) | ||||||
|  | 		.catch(function (error) { | ||||||
| 			alert(`Failed to set "${key}": ${JSON.stringify(error, null, 2)}.`); | 			alert(`Failed to set "${key}": ${JSON.stringify(error, null, 2)}.`); | ||||||
| 		}); | 		}); | ||||||
| } | } | ||||||
|  |  | ||||||
| window.addEventListener('load', function () { | window.addEventListener('load', function () { | ||||||
| 	const permission_template = (permission) => | 	const permission_template = (permission) => html` <code>${permission}</code>`; | ||||||
| 		html` <code>${permission}</code>`; |  | ||||||
| 	function input_template(key, description) { | 	function input_template(key, description) { | ||||||
| 		if (description.type === 'boolean') { | 		if (description.type === 'boolean') { | ||||||
| 			return html` | 			return html` | ||||||
| 				<div style="margin-top: 1em"> | 				<li class="w3-row"> | ||||||
| 					<label for=${'gs_' + key} style="font-weight: bold">${key}: </label> | 					<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${key}</label> | ||||||
| 					<div> | 					<div class="w3-quarter w3-padding">${description.description}</div> | ||||||
| 						<input type="checkbox" ?checked=${description.value} id=${'gs_' + key}></input> | 					<input class="w3-quarter w3-check" type="checkbox" ?checked=${description.value} id=${'gs_' + key}></input> | ||||||
| 						<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.checked)}>Set</button> | 					<button class="w3-quarter w3-button w3-theme-action" @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.checked)}>Set</button> | ||||||
| 						<div>${description.description}</div> | 				</li> | ||||||
| 					</div> |  | ||||||
| 				</div> |  | ||||||
| 			`; | 			`; | ||||||
| 		} else if (description.type === 'textarea') { | 		} else if (description.type === 'textarea') { | ||||||
| 			return html` | 			return html` | ||||||
| 				<div style="margin-top: 1em""> | 				<li class="w3-row"> | ||||||
| 					<label for=${'gs_' + key} style="font-weight: bold">${key}: </label> | 					<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${key}</label> | ||||||
| 					<div style="width: 100%; padding: 0; margin: 0"> | 					<div class="w3-rest w3-padding">${description.description}</div> | ||||||
| 						<div style="width: 90%; padding: 0 margin: 0"> | 					<textarea class="w3-input" style="vertical-align: top; resize: vertical" id=${'gs_' + key}>${description.value}</textarea> | ||||||
| 							<textarea style="vertical-align: top; width: 100%" rows=20 cols=80 id=${'gs_' + key}>${description.value}</textarea> | 					<button class="w3-button w3-right w3-quarter w3-theme-action" @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.value)}>Set</button> | ||||||
| 						</div> | 				</li> | ||||||
| 						<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.firstElementChild.value)}>Set</button> |  | ||||||
| 						<div>${description.description}</div> |  | ||||||
| 					</div> |  | ||||||
| 				</div> |  | ||||||
| 			`; | 			`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return html` | 			return html` | ||||||
| 				<div style="margin-top: 1em"> | 				<li class="w3-row"> | ||||||
| 					<label for=${'gs_' + key} style="font-weight: bold">${key}: </label> | 					<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${key}</label> | ||||||
| 					<div> | 					<div class="w3-quarter w3-padding">${description.description}</div> | ||||||
| 						<input type="text" value="${description.value}" id=${'gs_' + key}></input> | 					<input class="w3-input w3-quarter" type="text" value="${description.value}" id=${'gs_' + key}></input> | ||||||
| 						<button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.value)}>Set</button> | 					<button class="w3-button w3-quarter w3-theme-action" @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.value)}>Set</button> | ||||||
| 						<div>${description.description}</div> | 				</li> | ||||||
| 					</div> |  | ||||||
| 				</div> |  | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	const user_template = (user, permissions) => html` | 	const user_template = (user, permissions) => html` | ||||||
| 		<li> | 		<li class="w3-card w3-margin"> | ||||||
| 			<button @click=${(e) => delete_user(user)}> | 			<button class="w3-button w3-theme-action" @click=${(e) => delete_user(user)}>Delete</button> | ||||||
| 				Delete | 			${user}: ${permissions.map((x) => permission_template(x))} | ||||||
| 			</button> |  | ||||||
| 			${user}: |  | ||||||
| 			${permissions.map(x => permission_template(x))} |  | ||||||
| 		</li> | 		</li> | ||||||
| 	`; | 	`; | ||||||
| 	const users_template = (users) => | 	const users_template = (users) => | ||||||
| 		html`<h2>Users</h2> | 		html` | ||||||
| 		<ul> | 			<header class="w3-container w3-theme-l2"><h2>Users</h2></header> | ||||||
| 			${Object.entries(users).map(u => user_template(u[0], u[1]))} | 			<ul class="w3-ul"> | ||||||
|  | 				${Object.entries(users).map((u) => user_template(u[0], u[1]))} | ||||||
| 			</ul>`; | 			</ul>`; | ||||||
| 	const page_template = (data) => | 	const page_template = (data) => | ||||||
| 		html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%"> | 		html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%"> | ||||||
| 			<h2>Global Settings</h2> | 			<header class="w3-container w3-theme-l2"><h2>Global Settings</h2></header> | ||||||
| 			<div> | 			<div class="w3-container"> | ||||||
| 			${Object.keys(data.settings).sort().map(x => html`${input_template(x, data.settings[x])}`)} | 				<ul class="w3-ul"> | ||||||
|  | 					${Object.keys(data.settings) | ||||||
|  | 						.sort() | ||||||
|  | 						.map((x) => html`${input_template(x, data.settings[x])}`)} | ||||||
|  | 				</ul> | ||||||
| 			</div> | 			</div> | ||||||
| 			${users_template(data.users)} | 			${users_template(data.users)} | ||||||
| 		</div> | 		</div> `; | ||||||
| 		`; |  | ||||||
| 	render(page_template(g_data), document.body); | 	render(page_template(g_data), document.body); | ||||||
| }); | }); | ||||||
							
								
								
									
										235
									
								
								apps/admin/w3.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										235
									
								
								apps/admin/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} | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
| 	"type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
| 	"emoji": "💻", | 	"emoji": "💻", | ||||||
|   "previous": "&33ngNe0YrH3JScss6krlCwddZcXl8C5szonp7DYy4qA=.sha256" | 	"previous": "&icsPplXHgmpkbNWyo/WTvRdT/A8BXxW4lJixOtP4ueQ=.sha256" | ||||||
| } | } | ||||||
							
								
								
									
										112
									
								
								apps/apps/app.js
									
									
									
									
									
								
							
							
						
						
									
										112
									
								
								apps/apps/app.js
									
									
									
									
									
								
							| @@ -1,20 +1,37 @@ | |||||||
|  | /** | ||||||
|  |  * Fetches information about the applications | ||||||
|  |  * @param apps Record<appName, blobId> | ||||||
|  |  * @returns an object including the apps' name, emoji, and blobs ids | ||||||
|  |  */ | ||||||
| async function fetch_info(apps) { | async function fetch_info(apps) { | ||||||
| 	let result = {}; | 	let result = {}; | ||||||
|  |  | ||||||
|  | 	// For each app | ||||||
| 	for (let [key, value] of Object.entries(apps)) { | 	for (let [key, value] of Object.entries(apps)) { | ||||||
|  | 		// Get it's blob and parse it | ||||||
| 		let blob = await ssb.blobGet(value); | 		let blob = await ssb.blobGet(value); | ||||||
| 		blob = blob ? utf8Decode(blob) : '{}'; | 		blob = blob ? utf8Decode(blob) : '{}'; | ||||||
|  |  | ||||||
|  | 		// Add it to the result object | ||||||
| 		result[key] = JSON.parse(blob); | 		result[key] = JSON.parse(blob); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return result; | 	return result; | ||||||
| } | } | ||||||
|  |  | ||||||
|  | /** | ||||||
|  |  * | ||||||
|  |  * | ||||||
|  |  */ | ||||||
| async function fetch_shared_apps() { | async function fetch_shared_apps() { | ||||||
| 	let messages = {}; | 	let messages = {}; | ||||||
| 	await ssb.sqlAsync(` |  | ||||||
| 				SELECT messages.* | 	await ssb.sqlAsync( | ||||||
|  | 		` | ||||||
|  | 				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 				FROM messages_fts('"application/tildefriends"') | 				FROM messages_fts('"application/tildefriends"') | ||||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||||
| 				ORDER BY timestamp | 				ORDER BY messages.timestamp | ||||||
| 		`, | 		`, | ||||||
| 		[], | 		[], | ||||||
| 		function (row) { | 		function (row) { | ||||||
| @@ -28,9 +45,13 @@ async function fetch_shared_apps() { | |||||||
| 					}; | 					}; | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
|  |  | ||||||
| 	let result = {}; | 	let result = {}; | ||||||
| 	for (let app of Object.values(messages).sort((x, y) => y.message.timestamp - x.message.timestamp)) { | 	for (let app of Object.values(messages).sort( | ||||||
|  | 		(x, y) => y.message.timestamp - x.message.timestamp | ||||||
|  | 	)) { | ||||||
| 		let app_object = JSON.parse(utf8Decode(await ssb.blobGet(app.blob))); | 		let app_object = JSON.parse(utf8Decode(await ssb.blobGet(app.blob))); | ||||||
| 		if (app_object) { | 		if (app_object) { | ||||||
| 			app_object.blob_id = app.blob; | 			app_object.blob_id = app.blob; | ||||||
| @@ -41,18 +62,26 @@ async function fetch_shared_apps() { | |||||||
| } | } | ||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| 	var apps = await fetch_info(await core.apps()); | 	const apps = await fetch_info(await core.apps()); | ||||||
| 	var core_apps = await fetch_info(await core.apps('core')); | 	const core_apps = await fetch_info(await core.apps('core')); | ||||||
| 	let shared_apps = await fetch_shared_apps(); | 	const shared_apps = await fetch_shared_apps(); | ||||||
| 	var doc = `<!DOCTYPE html> |  | ||||||
| <html> | 	const stylesheet = ` | ||||||
| <head> | 		body { | ||||||
| 	<style> | 			color: whitesmoke; | ||||||
|  | 			font-family: sans-serif; | ||||||
|  | 			margin: 16px; | ||||||
|  | 		} | ||||||
| 		.container { | 		.container { | ||||||
| 			display: grid; | 			display: grid; | ||||||
| 			grid-template-columns: repeat(auto-fill, 64px); | 			grid-template-columns: repeat(auto-fill, 64px); | ||||||
|  | 			gap: 1em; | ||||||
| 			justify-content: space-around; | 			justify-content: space-around; | ||||||
|  | 			background-color: #ffffff10; | ||||||
|  | 			border: 2px solid #073642; | ||||||
|  | 			border-radius: 8px; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		.app { | 		.app { | ||||||
| 			height: 96px; | 			height: 96px; | ||||||
| 			width: 64px; | 			width: 64px; | ||||||
| @@ -67,24 +96,42 @@ async function main() { | |||||||
| 			max-width: 64px; | 			max-width: 64px; | ||||||
| 			text-overflow: ellipsis ellipsis; | 			text-overflow: ellipsis ellipsis; | ||||||
| 			overflow: hidden; | 			overflow: hidden; | ||||||
|  | 			color: whitesmoke; | ||||||
| 		} | 		} | ||||||
| 	</style> | 	`; | ||||||
| </head> |  | ||||||
| <body style="background: #888"> | 	const body = ` | ||||||
| <h1 id="apps_title">Apps</h1> | 		<h1 style="text-shadow: #808080 0 0 10px;">Welcome to Tilde Friends.</h1> | ||||||
|  |  | ||||||
|  | 		<h2>your apps</h2> | ||||||
| 		<div id="apps" class="container"></div> | 		<div id="apps" class="container"></div> | ||||||
| <h1>Shared Apps</h1> |  | ||||||
|  | 		<h2>shared apps</h2> | ||||||
| 		<div id="shared_apps" class="container"></div> | 		<div id="shared_apps" class="container"></div> | ||||||
| <h1>Core Apps</h1> |  | ||||||
|  | 		<h2>core apps</h2> | ||||||
| 		<div id="core_apps" class="container"></div> | 		<div id="core_apps" class="container"></div> | ||||||
| </body> | 	`; | ||||||
| <script> |  | ||||||
|  | 	const script = ` | ||||||
|  | 		/* | ||||||
|  | 		 * Creates a list of apps | ||||||
|  | 		 * @param id the id of the element to populate | ||||||
|  | 		 * @param name (a username, 'core' or undefined) | ||||||
|  | 		 * @param apps Object, a list of apps | ||||||
|  | 		 */ | ||||||
| 		function populate_apps(id, name, apps) { | 		function populate_apps(id, name, apps) { | ||||||
|  | 			// Our target | ||||||
| 			var list = document.getElementById(id); | 			var list = document.getElementById(id); | ||||||
|  |  | ||||||
|  | 			// For each app in the provided list | ||||||
| 			for (let app of Object.keys(apps).sort()) { | 			for (let app of Object.keys(apps).sort()) { | ||||||
|  |  | ||||||
|  | 				// Create the item | ||||||
| 				let div = list.appendChild(document.createElement('div')); | 				let div = list.appendChild(document.createElement('div')); | ||||||
| 				div.classList.add('app'); | 				div.classList.add('app'); | ||||||
|  |  | ||||||
|  | 				// The app's icon | ||||||
| 				let href = name ? '/~' + name + '/' + app + '/' : ('/' + apps[app].blob_id + '/'); | 				let href = name ? '/~' + name + '/' + app + '/' : ('/' + apps[app].blob_id + '/'); | ||||||
| 				let icon_a = document.createElement('a'); | 				let icon_a = document.createElement('a'); | ||||||
| 				let icon = document.createElement('div'); | 				let icon = document.createElement('div'); | ||||||
| @@ -95,6 +142,7 @@ async function main() { | |||||||
| 				icon_a.target = '_top'; | 				icon_a.target = '_top'; | ||||||
| 				div.appendChild(icon_a); | 				div.appendChild(icon_a); | ||||||
|  |  | ||||||
|  | 				// The app's name | ||||||
| 				let a = document.createElement('a'); | 				let a = document.createElement('a'); | ||||||
| 				a.appendChild(document.createTextNode(app)); | 				a.appendChild(document.createTextNode(app)); | ||||||
| 				a.href = href; | 				a.href = href; | ||||||
| @@ -102,13 +150,33 @@ async function main() { | |||||||
| 				div.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('apps', '${core.user.credentials?.session?.name}', ${JSON.stringify(apps)}); | ||||||
| 		populate_apps('core_apps', 'core', ${JSON.stringify(core_apps)}); | 		populate_apps('core_apps', 'core', ${JSON.stringify(core_apps)}); | ||||||
| 		populate_apps('shared_apps', undefined, ${JSON.stringify(shared_apps)}); | 		populate_apps('shared_apps', undefined, ${JSON.stringify(shared_apps)}); | ||||||
|  | 	`; | ||||||
|  |  | ||||||
|  | 	// Build the document | ||||||
|  | 	const document = ` | ||||||
|  | 	<!DOCTYPE html> | ||||||
|  | 	<html> | ||||||
|  | 		<head> | ||||||
|  | 			<style> | ||||||
|  | 				${stylesheet} | ||||||
|  | 			</style> | ||||||
|  | 		</head> | ||||||
|  |  | ||||||
|  | 		<body> | ||||||
|  | 			${body} | ||||||
|  | 		</body> | ||||||
|  |  | ||||||
|  | 		<script> | ||||||
|  | 			${script} | ||||||
| 		</script> | 		</script> | ||||||
| 	</html>`; | 	</html>`; | ||||||
| 	app.setDocument(doc); |  | ||||||
|  | 	// Send it to the browser | ||||||
|  | 	app.setDocument(document); | ||||||
| } | } | ||||||
|  |  | ||||||
| main(); | main(); | ||||||
|   | |||||||
| @@ -1,11 +1,19 @@ | |||||||
| import * as commonmark from './commonmark.min.js'; | import * as commonmark from './commonmark.min.js'; | ||||||
|  |  | ||||||
| function escape(text) { | function escape(text) { | ||||||
| 	return (text ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); | 	return (text ?? '') | ||||||
|  | 		.replaceAll('&', '&') | ||||||
|  | 		.replaceAll('<', '<') | ||||||
|  | 		.replaceAll('>', '>'); | ||||||
| } | } | ||||||
|  |  | ||||||
| function escapeAttribute(text) { | function escapeAttribute(text) { | ||||||
| 	return (text ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", '''); | 	return (text ?? '') | ||||||
|  | 		.replaceAll('&', '&') | ||||||
|  | 		.replaceAll('<', '<') | ||||||
|  | 		.replaceAll('>', '>') | ||||||
|  | 		.replaceAll('"', '"') | ||||||
|  | 		.replaceAll("'", '''); | ||||||
| } | } | ||||||
|  |  | ||||||
| export async function get_blog_message(id) { | export async function get_blog_message(id) { | ||||||
| @@ -21,7 +29,8 @@ export async function get_blog_message(id) { | |||||||
| 				blog: content?.blog, | 				blog: content?.blog, | ||||||
| 				title: content?.title, | 				title: content?.title, | ||||||
| 			}; | 			}; | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
| 	if (message) { | 	if (message) { | ||||||
| 		await ssb.sqlAsync( | 		await ssb.sqlAsync( | ||||||
| 			` | 			` | ||||||
| @@ -36,7 +45,8 @@ export async function get_blog_message(id) { | |||||||
| 			[message.author], | 			[message.author], | ||||||
| 			function (row) { | 			function (row) { | ||||||
| 				message.name = row.name; | 				message.name = row.name; | ||||||
| 			}); | 			} | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
| 	return message; | 	return message; | ||||||
| } | } | ||||||
| @@ -51,8 +61,12 @@ export function markdown(md) { | |||||||
| 		node = event.node; | 		node = event.node; | ||||||
| 		if (event.entering) { | 		if (event.entering) { | ||||||
| 			if (node.destination?.startsWith('&')) { | 			if (node.destination?.startsWith('&')) { | ||||||
| 				node.destination = '/' + node.destination + '/view?filename=' + node.firstChild?.literal; | 				node.destination = | ||||||
| 			} else if (node.destination?.startsWith('@') || node.destination?.startsWith('%')) { | 					'/' + node.destination + '/view?filename=' + node.firstChild?.literal; | ||||||
|  | 			} else if ( | ||||||
|  | 				node.destination?.startsWith('@') || | ||||||
|  | 				node.destination?.startsWith('%') | ||||||
|  | 			) { | ||||||
| 				node.destination = '/~core/ssb/#' + escape(node.destination); | 				node.destination = '/~core/ssb/#' + escape(node.destination); | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| @@ -107,7 +121,7 @@ export function render_html(blogs) { | |||||||
| 					<h1>🪵Tilde Friends Blog</h1> | 					<h1>🪵Tilde Friends Blog</h1> | ||||||
| 					<div style="font-size: xx-small; vertical-align: middle"><a href="/~cory/blog/atom">atom feed</a></div> | 					<div style="font-size: xx-small; vertical-align: middle"><a href="/~cory/blog/atom">atom feed</a></div> | ||||||
| 				</div> | 				</div> | ||||||
| 				${blogs.map(blog_post => render_blog_post(blog_post)).join('\n')} | 				${blogs.map((blog_post) => render_blog_post(blog_post)).join('\n')} | ||||||
| 			</body> | 			</body> | ||||||
| 		</html>`; | 		</html>`; | ||||||
| } | } | ||||||
| @@ -135,14 +149,15 @@ export function render_atom(blogs) { | |||||||
| 	<link href="${core.url}"/> | 	<link href="${core.url}"/> | ||||||
| 	<id>${core.url}</id> | 	<id>${core.url}</id> | ||||||
| 	<updated>${new Date().toString()}</updated> | 	<updated>${new Date().toString()}</updated> | ||||||
| 	${blogs.map(blog_post => render_blog_post_atom(blog_post)).join('\n')} | 	${blogs.map((blog_post) => render_blog_post_atom(blog_post)).join('\n')} | ||||||
| </feed>`; | </feed>`; | ||||||
| } | } | ||||||
|  |  | ||||||
| export async function get_posts() { | export async function get_posts() { | ||||||
| 	let blogs = []; | 	let blogs = []; | ||||||
| 	let ids = await ssb.getIdentities(); | 	let ids = await ssb.getIdentities(); | ||||||
| 	await ssb.sqlAsync(` | 	await ssb.sqlAsync( | ||||||
|  | 		` | ||||||
| 		WITH | 		WITH | ||||||
| 			blogs AS ( | 			blogs AS ( | ||||||
| 				SELECT | 				SELECT | ||||||
| @@ -182,8 +197,11 @@ export async function get_posts() { | |||||||
| 		JOIN public ON public.author = blogs.author | 		JOIN public ON public.author = blogs.author | ||||||
| 		LEFT OUTER JOIN names ON names.author = blogs.author | 		LEFT OUTER JOIN names ON names.author = blogs.author | ||||||
| 		ORDER BY blogs.timestamp DESC LIMIT 20 | 		ORDER BY blogs.timestamp DESC LIMIT 20 | ||||||
| 	`, [JSON.stringify(ids)], function(row) { | 	`, | ||||||
|  | 		[JSON.stringify(ids)], | ||||||
|  | 		function (row) { | ||||||
| 			blogs.push(row); | 			blogs.push(row); | ||||||
| 	}); | 		} | ||||||
|  | 	); | ||||||
| 	return blogs; | 	return blogs; | ||||||
| } | } | ||||||
| @@ -2,30 +2,50 @@ import * as blog from './blog.js'; | |||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| 	if (request.path.startsWith('%') && request.path.endsWith('.sha256')) { | 	if (request.path.startsWith('%') && request.path.endsWith('.sha256')) { | ||||||
| 		let id = request.path.startsWith('%25') ? '%' + request.path.substring(3) : request.path; | 		let id = request.path.startsWith('%25') | ||||||
|  | 			? '%' + request.path.substring(3) | ||||||
|  | 			: request.path; | ||||||
| 		let message = await blog.get_blog_message(id); | 		let message = await blog.get_blog_message(id); | ||||||
| 		if (message) { | 		if (message) { | ||||||
| 			respond({data: await blog.render_blog_post_html(message), content_type: 'text/html; charset=utf-8'}); | 			respond({ | ||||||
|  | 				data: await blog.render_blog_post_html(message), | ||||||
|  | 				content_type: 'text/html; charset=utf-8', | ||||||
|  | 			}); | ||||||
| 		} else { | 		} else { | ||||||
| 			respond({data: `Message ${id} not found.`, content_type: 'text/html; charset=utf-8'}); | 			respond({ | ||||||
|  | 				data: `Message ${id} not found.`, | ||||||
|  | 				content_type: 'text/html; charset=utf-8', | ||||||
|  | 			}); | ||||||
| 		} | 		} | ||||||
| 	} else if (request.path == 'atom') { | 	} else if (request.path == 'atom') { | ||||||
| 		let blogs = await blog.get_posts(); | 		let blogs = await blog.get_posts(); | ||||||
| 		respond({data: blog.render_atom(blogs), content_type: 'application/atom+xml'}); | 		respond({ | ||||||
|  | 			data: blog.render_atom(blogs), | ||||||
|  | 			content_type: 'application/atom+xml', | ||||||
|  | 		}); | ||||||
| 	} else { | 	} else { | ||||||
| 		let blogs = await blog.get_posts(); | 		let blogs = await blog.get_posts(); | ||||||
| 		for (let blog_post of blogs) { | 		for (let blog_post of blogs) { | ||||||
| 			let title = (blog_post.title || '').replaceAll(/\W/g, '_').toLowerCase(); | 			let title = (blog_post.title || '').replaceAll(/\W/g, '_').toLowerCase(); | ||||||
| 			if (request.path === title) { | 			if (request.path === title) { | ||||||
| 				respond({data: await blog.render_blog_post_html(blog_post), content_type: 'text/html; charset=utf-8'}); | 				respond({ | ||||||
|  | 					data: await blog.render_blog_post_html(blog_post), | ||||||
|  | 					content_type: 'text/html; charset=utf-8', | ||||||
|  | 				}); | ||||||
| 				return; | 				return; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		respond({data: blog.render_html(blogs), content_type: 'text/html; charset=utf-8'}); | 		respond({ | ||||||
|  | 			data: blog.render_html(blogs), | ||||||
|  | 			content_type: 'text/html; charset=utf-8', | ||||||
|  | 		}); | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
| main().catch(function (error) { | main().catch(function (error) { | ||||||
| 	respond({data: `<!DOCTYPE html> | 	respond({ | ||||||
| 	<pre style="color: #f00">${error.message}\n${error.stack}</pre>`, content_type: 'text/html'}); | 		data: `<!DOCTYPE html> | ||||||
|  | 	<pre style="color: #f00">${error.message}\n${error.stack}</pre>`, | ||||||
|  | 		content_type: 'text/html', | ||||||
|  | 	}); | ||||||
| }); | }); | ||||||
							
								
								
									
										22
									
								
								apps/blog/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								apps/blog/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
											
										
									
								
							| @@ -21,7 +21,8 @@ async function contacts_internal(id, last_row_id, following, max_row_id) { | |||||||
| 				json_extract(content, '$.type') = 'contact' | 				json_extract(content, '$.type') = 'contact' | ||||||
| 				ORDER BY sequence | 				ORDER BY sequence | ||||||
| 			`, | 			`, | ||||||
| 		[id, last_row_id, max_row_id]); | 		[id, last_row_id, max_row_id] | ||||||
|  | 	); | ||||||
| 	for (let row of contacts) { | 	for (let row of contacts) { | ||||||
| 		let contact = JSON.parse(row.content); | 		let contact = JSON.parse(row.content); | ||||||
| 		if (contact.following === true) { | 		if (contact.following === true) { | ||||||
| @@ -42,15 +43,34 @@ async function contact(id, last_row_id, following, max_row_id) { | |||||||
| 	return await contacts_internal(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) { | async function following_deep_internal( | ||||||
| 	let contacts = await Promise.all([...new Set(ids)].map(x => contact(x, last_row_id, following, max_row_id))); | 	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 = {}; | 	let result = {}; | ||||||
| 	for (let i = 0; i < ids.length; i++) { | 	for (let i = 0; i < ids.length; i++) { | ||||||
| 		let id = ids[i]; | 		let id = ids[i]; | ||||||
| 		let contact = contacts[i]; | 		let contact = contacts[i]; | ||||||
| 		let all_blocking = Object.assign({}, contact.blocking, blocking); | 		let all_blocking = Object.assign({}, contact.blocking, blocking); | ||||||
| 		let found = Object.keys(contact.following).filter(y => !all_blocking[y]); | 		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) : []; | 		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]; | 		result[id] = [id, ...found, ...deeper]; | ||||||
| 	} | 	} | ||||||
| 	return [...new Set(Object.values(result).flat())]; | 	return [...new Set(Object.values(result).flat())]; | ||||||
| @@ -68,10 +88,22 @@ async function following_deep(ids, depth, blocking) { | |||||||
| 			last_row_id: 0, | 			last_row_id: 0, | ||||||
| 		}; | 		}; | ||||||
| 	} | 	} | ||||||
| 	let max_row_id = (await query(` | 	let max_row_id = ( | ||||||
|  | 		await query( | ||||||
|  | 			` | ||||||
| 			SELECT MAX(rowid) AS max_row_id FROM messages | 			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); | 			[] | ||||||
|  | 		) | ||||||
|  | 	)[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; | 	cache.last_row_id = max_row_id; | ||||||
| 	let store = JSON.stringify(cache); | 	let store = JSON.stringify(cache); | ||||||
| 	await db.set('following', store); | 	await db.set('following', store); | ||||||
| @@ -90,13 +122,15 @@ async function fetch_about(db, ids, users) { | |||||||
| 		}; | 		}; | ||||||
| 	} | 	} | ||||||
| 	let max_row_id = 0; | 	let max_row_id = 0; | ||||||
| 	await ssb.sqlAsync(` | 	await ssb.sqlAsync( | ||||||
|  | 		` | ||||||
| 			SELECT MAX(rowid) AS max_row_id FROM messages | 			SELECT MAX(rowid) AS max_row_id FROM messages | ||||||
| 		`, | 		`, | ||||||
| 		[], | 		[], | ||||||
| 		function (row) { | 		function (row) { | ||||||
| 			max_row_id = row.max_row_id; | 			max_row_id = row.max_row_id; | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
| 	for (let id of Object.keys(cache.about)) { | 	for (let id of Object.keys(cache.about)) { | ||||||
| 		if (ids.indexOf(id) == -1) { | 		if (ids.indexOf(id) == -1) { | ||||||
| 			delete cache.about[id]; | 			delete cache.about[id]; | ||||||
| @@ -129,17 +163,21 @@ async function fetch_about(db, ids, users) { | |||||||
| 				ORDER BY messages.author, messages.sequence | 				ORDER BY messages.author, messages.sequence | ||||||
| 			`, | 			`, | ||||||
| 		[ | 		[ | ||||||
| 			JSON.stringify(ids.filter(id => cache.about[id])), | 			JSON.stringify(ids.filter((id) => cache.about[id])), | ||||||
| 			JSON.stringify(ids.filter(id => !cache.about[id])), | 			JSON.stringify(ids.filter((id) => !cache.about[id])), | ||||||
| 			cache.last_row_id, | 			cache.last_row_id, | ||||||
| 			max_row_id, | 			max_row_id, | ||||||
| 		]); | 		] | ||||||
|  | 	); | ||||||
| 	for (let about of abouts) { | 	for (let about of abouts) { | ||||||
| 		let content = JSON.parse(about.content); | 		let content = JSON.parse(about.content); | ||||||
| 		if (content.about === about.author) { | 		if (content.about === about.author) { | ||||||
| 			delete content.type; | 			delete content.type; | ||||||
| 			delete content.about; | 			delete content.about; | ||||||
| 			cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content); | 			cache.about[about.author] = Object.assign( | ||||||
|  | 				cache.about[about.author] || {}, | ||||||
|  | 				content | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| 	cache.last_row_id = max_row_id; | 	cache.last_row_id = max_row_id; | ||||||
| @@ -155,24 +193,24 @@ async function getAbout(db, id) { | |||||||
| 	if (g_about_cache[id]) { | 	if (g_about_cache[id]) { | ||||||
| 		return g_about_cache[id]; | 		return g_about_cache[id]; | ||||||
| 	} | 	} | ||||||
| 	let o = await db.get(id + ":about"); | 	let o = await db.get(id + ':about'); | ||||||
| 	const k_version = 4; | 	const k_version = 4; | ||||||
| 	let f = o ? JSON.parse(o) : o; | 	let f = o ? JSON.parse(o) : o; | ||||||
| 	if (!f || f.version != k_version) { | 	if (!f || f.version != k_version) { | ||||||
| 		f = {about: {}, sequence: 0, version: k_version}; | 		f = {about: {}, sequence: 0, version: k_version}; | ||||||
| 	} | 	} | ||||||
| 	await ssb.sqlAsync( | 	await ssb.sqlAsync( | ||||||
| 		"SELECT "+ | 		'SELECT ' + | ||||||
| 		"  sequence, "+ | 			'  sequence, ' + | ||||||
| 		"  content "+ | 			'  content ' + | ||||||
| 		"FROM messages "+ | 			'FROM messages ' + | ||||||
| 		"WHERE "+ | 			'WHERE ' + | ||||||
| 		"  author = ?1 AND "+ | 			'  author = ?1 AND ' + | ||||||
| 		"  sequence > ?2 AND "+ | 			'  sequence > ?2 AND ' + | ||||||
| 			"  json_extract(content, '$.type') = 'about' AND " + | 			"  json_extract(content, '$.type') = 'about' AND " + | ||||||
| 			"  json_extract(content, '$.about') = ?1 " + | 			"  json_extract(content, '$.about') = ?1 " + | ||||||
| 		"UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 "+ | 			'UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 ' + | ||||||
| 		"ORDER BY sequence", | 			'ORDER BY sequence', | ||||||
| 		[id, f.sequence], | 		[id, f.sequence], | ||||||
| 		function (row) { | 		function (row) { | ||||||
| 			f.sequence = row.sequence; | 			f.sequence = row.sequence; | ||||||
| @@ -180,16 +218,16 @@ async function getAbout(db, id) { | |||||||
| 				let about = {}; | 				let about = {}; | ||||||
| 				try { | 				try { | ||||||
| 					about = JSON.parse(row.content); | 					about = JSON.parse(row.content); | ||||||
| 				} catch { | 				} catch {} | ||||||
| 				} |  | ||||||
| 				delete about.about; | 				delete about.about; | ||||||
| 				delete about.type; | 				delete about.type; | ||||||
| 				f.about = Object.assign(f.about, about); | 				f.about = Object.assign(f.about, about); | ||||||
| 			} | 			} | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
| 	let j = JSON.stringify(f); | 	let j = JSON.stringify(f); | ||||||
| 	if (o != j) { | 	if (o != j) { | ||||||
| 		await db.set(id + ":about", j); | 		await db.set(id + ':about', j); | ||||||
| 	} | 	} | ||||||
| 	g_about_cache[id] = f.about; | 	g_about_cache[id] = f.about; | ||||||
| 	return f.about; | 	return f.about; | ||||||
| @@ -198,15 +236,15 @@ async function getAbout(db, id) { | |||||||
| async function getSize(db, id) { | async function getSize(db, id) { | ||||||
| 	let size = 0; | 	let size = 0; | ||||||
| 	await ssb.sqlAsync( | 	await ssb.sqlAsync( | ||||||
| 		"SELECT (LENGTH(author) * COUNT(*) + SUM(LENGTH(content)) + 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], | 		[id], | ||||||
| 		function (row) { | 		function (row) { | ||||||
| 			size += row.size; | 			size += row.size; | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
| 	return size; | 	return size; | ||||||
| } | } | ||||||
|  |  | ||||||
|  |  | ||||||
| async function getSizes(ids) { | async function getSizes(ids) { | ||||||
| 	let sizes = {}; | 	let sizes = {}; | ||||||
| 	await ssb.sqlAsync( | 	await ssb.sqlAsync( | ||||||
| @@ -221,7 +259,8 @@ async function getSizes(ids) { | |||||||
| 		[JSON.stringify(ids)], | 		[JSON.stringify(ids)], | ||||||
| 		function (row) { | 		function (row) { | ||||||
| 			sizes[row.author] = row.size; | 			sizes[row.author] = row.size; | ||||||
| 		}); | 		} | ||||||
|  | 	); | ||||||
| 	return sizes; | 	return sizes; | ||||||
| } | } | ||||||
|  |  | ||||||
| @@ -241,7 +280,10 @@ function niceSize(bytes) { | |||||||
| } | } | ||||||
|  |  | ||||||
| function escape(value) { | function escape(value) { | ||||||
| 	return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>'); | 	return value | ||||||
|  | 		.replaceAll('&', '&') | ||||||
|  | 		.replaceAll('<', '<') | ||||||
|  | 		.replaceAll('>', '>'); | ||||||
| } | } | ||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| @@ -249,19 +291,27 @@ async function main() { | |||||||
| 	let db = await database('ssb'); | 	let db = await database('ssb'); | ||||||
| 	let whoami = await ssb.getIdentities(); | 	let whoami = await ssb.getIdentities(); | ||||||
| 	let tree = ''; | 	let tree = ''; | ||||||
| 	await app.setDocument(`<pre style="color: #fff">Enumerating followed users...</pre>`); | 	await app.setDocument( | ||||||
|  | 		`<pre style="color: #fff">Enumerating followed users...</pre>` | ||||||
|  | 	); | ||||||
| 	let following = await following_deep(whoami, 2, {}); | 	let following = await following_deep(whoami, 2, {}); | ||||||
| 	await app.setDocument(`<pre style="color: #fff">Getting names and sizes...</pre>`); | 	await app.setDocument( | ||||||
|  | 		`<pre style="color: #fff">Getting names and sizes...</pre>` | ||||||
|  | 	); | ||||||
| 	let [about, sizes] = await Promise.all([ | 	let [about, sizes] = await Promise.all([ | ||||||
| 		fetch_about(db, following, {}), | 		fetch_about(db, following, {}), | ||||||
| 		getSizes(following), | 		getSizes(following), | ||||||
| 	]); | 	]); | ||||||
| 	await app.setDocument(`<pre style="color: #fff">Finishing...</pre>`); | 	await app.setDocument(`<pre style="color: #fff">Finishing...</pre>`); | ||||||
| 	following.sort((a, b) => ((sizes[b] ?? 0) - (sizes[a] ?? 0))); | 	following.sort((a, b) => (sizes[b] ?? 0) - (sizes[a] ?? 0)); | ||||||
| 	for (let id of following) { | 	for (let id of following) { | ||||||
| 		tree += `<li><a href="/~core/ssb/#${id}">${escape(about[id]?.name ?? id)}</a> ${niceSize(sizes[id] ?? 0)}</li>\n`; | 		tree += `<li><a href="/~core/ssb/#${id}">${escape(about[id]?.name ?? id)}</a> ${niceSize(sizes[id] ?? 0)}</li>\n`; | ||||||
| 	} | 	} | ||||||
| 	await app.setDocument('<!DOCTYPE html>\n<html>\n<body style="color: #fff"><h1>Following</h1>\n<ul>' + tree + '</ul>\n</body>\n</html>'); | 	await app.setDocument( | ||||||
|  | 		'<!DOCTYPE html>\n<html>\n<body style="color: #fff"><h1>Following</h1>\n<ul>' + | ||||||
|  | 			tree + | ||||||
|  | 			'</ul>\n</body>\n</html>' | ||||||
|  | 	); | ||||||
| } | } | ||||||
|  |  | ||||||
| main(); | main(); | ||||||
| @@ -1,5 +0,0 @@ | |||||||
| { |  | ||||||
|   "type": "tildefriends-app", |  | ||||||
|   "emoji": "🗺", |  | ||||||
|   "previous": "&0XSp+xdQwVtQ88bXzvWdH15Ex63hv5zUKTa4zx7HBGM=.sha256" |  | ||||||
| } |  | ||||||
| @@ -1,80 +0,0 @@ | |||||||
| 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(); |  | ||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -1,81 +0,0 @@ | |||||||
| 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; |  | ||||||
| } |  | ||||||
| @@ -1,21 +0,0 @@ | |||||||
| 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(); |  | ||||||
| @@ -1,14 +0,0 @@ | |||||||
| <!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> |  | ||||||
| @@ -1,661 +0,0 @@ | |||||||
| /* 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; |  | ||||||
| 		} |  | ||||||
| 	} |  | ||||||
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
							
								
								
									
										120
									
								
								apps/gg/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										120
									
								
								apps/gg/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							
										
											
												File diff suppressed because one or more lines are too long
											
										
									
								
							| @@ -1,158 +0,0 @@ | |||||||
| /** |  | ||||||
|  * 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 }; |  | ||||||
| @@ -1,807 +0,0 @@ | |||||||
| 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); |  | ||||||
| @@ -1,20 +0,0 @@ | |||||||
| 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`, |  | ||||||
| 	}); |  | ||||||
| } |  | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
| 	"type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
| 	"emoji": "🪪", | 	"emoji": "🪪", | ||||||
|   "previous": "&kgukkyDk1RxgfzgMH6H/0QeDPIuwPZypLuAFax21ljk=.sha256" | 	"previous": "&de7q4A59auHP/34bXgeNH05JZoxsGr5TjwXPvehWH30=.sha256" | ||||||
| } | } | ||||||
| @@ -18,7 +18,37 @@ tfrpc.register(async function reload() { | |||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| 	let ids = await ssb.getIdentities(); | 	let ids = await ssb.getIdentities(); | ||||||
| 	await app.setDocument(`<body style="color: #fff"> | 	await app.setDocument( | ||||||
|  | 		` | ||||||
|  | 		<head> | ||||||
|  | 			<link rel="stylesheet" href="w3.css"></link> | ||||||
|  | 			<style> | ||||||
|  | 				/* "2018 Sargasso Sea" */ | ||||||
|  | 				.w3-theme-l5 {color:#000 !important; background-color:#f3f4f7 !important} | ||||||
|  | 				.w3-theme-l4 {color:#000 !important; background-color:#d7dbe3 !important} | ||||||
|  | 				.w3-theme-l3 {color:#000 !important; background-color:#b0b6c8 !important} | ||||||
|  | 				.w3-theme-l2 {color:#fff !important; background-color:#8892ac !important} | ||||||
|  | 				.w3-theme-l1 {color:#fff !important; background-color:#636f8e !important} | ||||||
|  | 				.w3-theme-d1 {color:#fff !important; background-color:#40485c !important} | ||||||
|  | 				.w3-theme-d2 {color:#fff !important; background-color:#394052 !important} | ||||||
|  | 				.w3-theme-d3 {color:#fff !important; background-color:#323848 !important} | ||||||
|  | 				.w3-theme-d4 {color:#fff !important; background-color:#2b303d !important} | ||||||
|  | 				.w3-theme-d5 {color:#fff !important; background-color:#242833 !important} | ||||||
|  |  | ||||||
|  | 				.w3-theme-light {color:#000 !important; background-color:#f3f4f7 !important} | ||||||
|  | 				.w3-theme-dark {color:#fff !important; background-color:#242833 !important} | ||||||
|  | 				.w3-theme-action {color:#fff !important; background-color:#242833 !important} | ||||||
|  |  | ||||||
|  | 				.w3-theme {color:#fff !important; background-color:#485167 !important} | ||||||
|  | 				.w3-text-theme {color:#485167 !important} | ||||||
|  | 				.w3-border-theme {border-color:#485167 !important} | ||||||
|  |  | ||||||
|  | 				.w3-hover-theme:hover {color:#fff !important; background-color:#485167 !important} | ||||||
|  | 				.w3-hover-text-theme:hover {color:#485167 !important} | ||||||
|  | 				.w3-hover-border-theme:hover {border-color:#485167 !important} | ||||||
|  | 			</style> | ||||||
|  | 		</head> | ||||||
|  | 		<body class="w3-theme-l3"> | ||||||
| 		<script>const handler = {};</script> | 		<script>const handler = {};</script> | ||||||
| 		<script type="module"> | 		<script type="module"> | ||||||
| 			import * as tfrpc from '/static/tfrpc.js'; | 			import * as tfrpc from '/static/tfrpc.js'; | ||||||
| @@ -26,7 +56,8 @@ async function main() { | |||||||
| 				let id = event.srcElement.dataset.id; | 				let id = event.srcElement.dataset.id; | ||||||
| 				let element = document.createElement('textarea'); | 				let element = document.createElement('textarea'); | ||||||
| 				element.value = await tfrpc.rpc.get_private_key(id); | 				element.value = await tfrpc.rpc.get_private_key(id); | ||||||
| 				element.style = 'width: 100%; read-only: true'; | 				element.style = 'width: 100%; height: auto; read-only: true; resize: none'; | ||||||
|  | 				element.classList.add('w3-input'); | ||||||
| 				element.readOnly = true; | 				element.readOnly = true; | ||||||
| 				event.srcElement.parentElement.appendChild(element); | 				event.srcElement.parentElement.appendChild(element); | ||||||
| 				event.srcElement.onclick = event => handler.hide_id(event, element); | 				event.srcElement.onclick = event => handler.hide_id(event, element); | ||||||
| @@ -68,20 +99,36 @@ async function main() { | |||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		</script> | 		</script> | ||||||
| 		<h1>SSB Identity Management</h1> | 		<header class="w3-theme w3-padding"><h1>SSB Identity Management</h1></header> | ||||||
| 		<h2>Create a new identity</h2> | 		<div class="w3-card-4 w3-margin"> | ||||||
| 		<button id="create_id" onclick="handler.create_id()">Create Identity</button> | 			<header class="w3-container w3-theme-l2"><h2>Create a new identity</h2></header> | ||||||
| 		<h2>Import an SSB Identity from 12 BIP39 English Words</h2> | 			<footer class="w3-padding"> | ||||||
| 		<textarea id="add_id" style="width: 100%" rows="4"></textarea><button id="add" onclick="handler.add_id(event)">Import Identity</button> | 				<button id="create_id" onclick="handler.create_id()" class="w3-button w3-theme">Create Identity</button> | ||||||
| 		<h2>Identities</h2> | 			</footer> | ||||||
| 		<ul>`+ | 		</div> | ||||||
| 		ids.map(id => `<li> | 		<div class="w3-card-4 w3-margin"> | ||||||
| 			<button onclick="handler.export_id(event)" data-id="${id}">Export Identity</button> | 			<header class="w3-container w3-theme-l2"><h2>Import an SSB Identity from 12 BIP39 English Words</h2></header> | ||||||
| 			<button onclick="handler.delete_id(event)" data-id="${id}">Delete Identity</button> | 			<textarea id="add_id" style="width: 100%" rows="4" class="w3-input"></textarea> | ||||||
|  | 			<footer class="w3-padding"> | ||||||
|  | 				<button id="add" onclick="handler.add_id(event)" class="w3-button w3-theme">Import Identity</button> | ||||||
|  | 			</footer> | ||||||
|  | 		</div> | ||||||
|  | 		<div class="w3-card-4 w3-margin"> | ||||||
|  | 			<header class="w3-container w3-theme-l2"><h2>Identities</h2></header> | ||||||
|  | 			<ul class="w3-ul">` + | ||||||
|  | 				ids | ||||||
|  | 					.map( | ||||||
|  | 						(id) => `<li style="overflow: hidden; text-wrap: nowrap; text-overflow: ellipsis"> | ||||||
|  | 				<button onclick="handler.export_id(event)" data-id="${id}" class="w3-button w3-theme">Export Identity</button> | ||||||
|  | 				<button onclick="handler.delete_id(event)" data-id="${id}" class="w3-button w3-theme">Delete Identity</button> | ||||||
| 				${id} | 				${id} | ||||||
| 		</li>`).join('\n')+ | 			</li>` | ||||||
|  | 					) | ||||||
|  | 					.join('\n') + | ||||||
| 				`	</ul> | 				`	</ul> | ||||||
| 	</body>`); | 		</div> | ||||||
|  | 	</body>` | ||||||
|  | 	); | ||||||
| } | } | ||||||
|  |  | ||||||
| main(); | main(); | ||||||
|   | |||||||
							
								
								
									
										235
									
								
								apps/identity/w3.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										235
									
								
								apps/identity/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} | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
| 	"type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
| 	"emoji": "🦟", | 	"emoji": "🦟", | ||||||
|   "previous": "&TegdzvFE+im94shygaHkgDYSaSrwY2h0OKUXSRPBQDM=.sha256" | 	"previous": "&cUqvSDUls3jn0haD85LPFAGdkc8wFuy347TtATNcJgg=.sha256" | ||||||
| } | } | ||||||
| @@ -85,6 +85,9 @@ tfrpc.register(async function store_message(message) { | |||||||
| tfrpc.register(function apps() { | tfrpc.register(function apps() { | ||||||
| 	return core.apps(); | 	return core.apps(); | ||||||
| }); | }); | ||||||
|  | tfrpc.register(function getActiveIdentity() { | ||||||
|  | 	return ssb.getActiveIdentity(); | ||||||
|  | }); | ||||||
| tfrpc.register(async function try_decrypt(id, content) { | tfrpc.register(async function try_decrypt(id, content) { | ||||||
| 	return await ssb.privateMessageDecrypt(id, content); | 	return await ssb.privateMessageDecrypt(id, content); | ||||||
| }); | }); | ||||||
| @@ -97,7 +100,7 @@ core.register('onConnectionsChanged', async function() { | |||||||
| }); | }); | ||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| 	if (typeof(database) !== 'undefined') { | 	if (typeof database !== 'undefined') { | ||||||
| 		g_database = await database('ssb'); | 		g_database = await database('ssb'); | ||||||
| 	} | 	} | ||||||
| 	await app.setDocument(utf8Decode(await getFile('index.html'))); | 	await app.setDocument(utf8Decode(await getFile('index.html'))); | ||||||
|   | |||||||
| @@ -1,12 +1,14 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html style="color: #fff"> | <html style="color: #fff"> | ||||||
| 	<head> | 	<head> | ||||||
| 		<title>Tilde Friends</title> | 		<title>Tilde Friends</title> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body> | 	<body> | ||||||
| 		<tf-issues-app /> | 		<tf-issues-app /> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script src="commonmark.min.js"></script> | 		<script src="commonmark.min.js"></script> | ||||||
| 		<script src="commonmark-linkify.js" type="module"></script> | 		<script src="commonmark-linkify.js" type="module"></script> | ||||||
| 		<script src="script.js" type="module"></script> | 		<script src="script.js" type="module"></script> | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								apps/issues/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								apps/issues/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
											
										
									
								
							| @@ -4,43 +4,6 @@ import * as tfutils from './tf-utils.js'; | |||||||
|  |  | ||||||
| const k_project = '%Hr+4xEVtjplidSKBlRWi4Aw/0Tfw7B+1OR9BzlDKmOI=.sha256'; | 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 { | class TfComposeElement extends LitElement { | ||||||
| 	static get properties() { | 	static get properties() { | ||||||
| 		return { | 		return { | ||||||
| @@ -57,13 +20,15 @@ class TfComposeElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	submit() { | 	submit() { | ||||||
| 		this.dispatchEvent(new CustomEvent('tf-submit', { | 		this.dispatchEvent( | ||||||
|  | 			new CustomEvent('tf-submit', { | ||||||
| 				bubbles: true, | 				bubbles: true, | ||||||
| 				composed: true, | 				composed: true, | ||||||
| 				detail: { | 				detail: { | ||||||
| 					value: this.renderRoot.getElementById('input').value, | 					value: this.renderRoot.getElementById('input').value, | ||||||
| 				}, | 				}, | ||||||
| 		})); | 			}) | ||||||
|  | 		); | ||||||
| 		this.renderRoot.getElementById('input').value = ''; | 		this.renderRoot.getElementById('input').value = ''; | ||||||
| 		this.input(); | 		this.input(); | ||||||
| 	} | 	} | ||||||
| @@ -96,18 +61,21 @@ class TfIssuesAppElement extends LitElement { | |||||||
|  |  | ||||||
| 	async load() { | 	async load() { | ||||||
| 		let issues = {}; | 		let issues = {}; | ||||||
| 		let messages = await tfrpc.rpc.query(` | 		let messages = await tfrpc.rpc.query( | ||||||
| 			WITH issues AS (SELECT messages.* FROM messages_refs JOIN messages ON | 			` | ||||||
|  | 			WITH issues AS (SELECT messages.id, json(messages.content) AS content, messages.author, messages.timestamp FROM messages_refs JOIN messages ON | ||||||
| 				messages.id = messages_refs.message | 				messages.id = messages_refs.message | ||||||
| 				WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'), | 				WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'), | ||||||
| 			edits AS (SELECT messages.* FROM issues JOIN messages_refs ON | 			edits AS (SELECT messages.id, json(messages.content) AS content, messages.author, messages.timestamp FROM issues JOIN messages_refs ON | ||||||
| 				issues.id = messages_refs.ref JOIN messages ON | 				issues.id = messages_refs.ref JOIN messages ON | ||||||
| 				messages.id = messages_refs.message | 				messages.id = messages_refs.message | ||||||
| 				WHERE json_extract(messages.content, '$.type') IN ('issue-edit', 'post')) | 				WHERE json_extract(messages.content, '$.type') IN ('issue-edit', 'post')) | ||||||
| 			SELECT * FROM issues | 			SELECT * FROM issues | ||||||
| 			UNION | 			UNION | ||||||
| 			SELECT * FROM edits ORDER BY timestamp | 			SELECT * FROM edits ORDER BY timestamp | ||||||
| 		`, [k_project]); | 		`, | ||||||
|  | 			[k_project] | ||||||
|  | 		); | ||||||
| 		for (let message of messages) { | 		for (let message of messages) { | ||||||
| 			let content = JSON.parse(message.content); | 			let content = JSON.parse(message.content); | ||||||
| 			switch (content.type) { | 			switch (content.type) { | ||||||
| @@ -123,7 +91,7 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 					break; | 					break; | ||||||
| 				case 'issue-edit': | 				case 'issue-edit': | ||||||
| 				case 'post': | 				case 'post': | ||||||
| 					for (let issue of (content.issues || [])) { | 					for (let issue of content.issues || []) { | ||||||
| 						if (issues[issue.link]) { | 						if (issues[issue.link]) { | ||||||
| 							if (issue.open !== undefined) { | 							if (issue.open !== undefined) { | ||||||
| 								issues[issue.link].open = issue.open; | 								issues[issue.link].open = issue.open; | ||||||
| @@ -136,7 +104,9 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 					break; | 					break; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		this.issues = Object.values(issues).sort((x, y) => (y.open - x.open) || (y.created - x.created)); | 		this.issues = Object.values(issues).sort( | ||||||
|  | 			(x, y) => y.open - x.open || y.created - x.created | ||||||
|  | 		); | ||||||
| 		if (this.selected) { | 		if (this.selected) { | ||||||
| 			for (let issue of this.issues) { | 			for (let issue of this.issues) { | ||||||
| 				if (issue.id == this.selected.id) { | 				if (issue.id == this.selected.id) { | ||||||
| @@ -150,11 +120,20 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 		return html` | 		return html` | ||||||
| 			<tr> | 			<tr> | ||||||
| 				<td>${issue.open ? '☐ open' : '☑ closed'}</td> | 				<td>${issue.open ? '☐ open' : '☑ closed'}</td> | ||||||
| 				<td style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis">${issue.author}</td> | 				<td | ||||||
| 				<td style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer" @click=${() => this.selected = issue}> | 					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]} | 					${issue.text.split('\n')?.[0]} | ||||||
| 				</td> | 				</td> | ||||||
| 				<td>${new Date(issue.updated ?? issue.created).toLocaleDateString()}</td> | 				<td> | ||||||
|  | 					${new Date(issue.updated ?? issue.created).toLocaleDateString()} | ||||||
|  | 				</td> | ||||||
| 			</tr> | 			</tr> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| @@ -170,14 +149,22 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 				<div>${new Date(update.timestamp).toLocaleString()}</div> | 				<div>${new Date(update.timestamp).toLocaleString()}</div> | ||||||
| 				<div>${update.author}</div> | 				<div>${update.author}</div> | ||||||
| 				<div>${message}</div> | 				<div>${message}</div> | ||||||
| 				<div>${update.open !== undefined ? (update.open ? 'issue opened' : 'issue closed') : undefined}</div> | 				<div> | ||||||
|  | 					${update.open !== undefined | ||||||
|  | 						? update.open | ||||||
|  | 							? 'issue opened' | ||||||
|  | 							: 'issue closed' | ||||||
|  | 						: undefined} | ||||||
|  | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async set_open(id, open) { | 	async set_open(id, open) { | ||||||
| 		if (confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`)) { | 		if ( | ||||||
| 			let whoami = this.shadowRoot.getElementById('picker').selected; | 			confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`) | ||||||
|  | 		) { | ||||||
|  | 			let whoami = await tfrpc.rpc.getActiveIdentity(); | ||||||
| 			await tfrpc.rpc.appendMessage(whoami, { | 			await tfrpc.rpc.appendMessage(whoami, { | ||||||
| 				type: 'issue-edit', | 				type: 'issue-edit', | ||||||
| 				issues: [ | 				issues: [ | ||||||
| @@ -192,7 +179,7 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async create_issue(event) { | 	async create_issue(event) { | ||||||
| 		let whoami = this.shadowRoot.getElementById('picker').selected; | 		let whoami = await tfrpc.rpc.getActiveIdentity(); | ||||||
| 		await tfrpc.rpc.appendMessage(whoami, { | 		await tfrpc.rpc.appendMessage(whoami, { | ||||||
| 			type: 'issue', | 			type: 'issue', | ||||||
| 			project: k_project, | 			project: k_project, | ||||||
| @@ -202,12 +189,14 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async reply_to_issue(event) { | 	async reply_to_issue(event) { | ||||||
| 		let whoami = this.shadowRoot.getElementById('picker').selected; | 		let whoami = await tfrpc.rpc.getActiveIdentity(); | ||||||
| 		await tfrpc.rpc.appendMessage(whoami, { | 		await tfrpc.rpc.appendMessage(whoami, { | ||||||
| 			type: 'post', | 			type: 'post', | ||||||
| 			text: event.detail.value, | 			text: event.detail.value, | ||||||
| 			root: this.selected.id, | 			root: this.selected.id, | ||||||
| 			branch: this.selected.updates.length ? this.selected.updates[this.selected.updates.length - 1].id : this.selected.id, | 			branch: this.selected.updates.length | ||||||
|  | 				? this.selected.updates[this.selected.updates.length - 1].id | ||||||
|  | 				: this.selected.id, | ||||||
| 			issues: [ | 			issues: [ | ||||||
| 				{ | 				{ | ||||||
| 					link: this.selected.id, | 					link: this.selected.id, | ||||||
| @@ -218,24 +207,23 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		let header = html` | 		let header = html` <h1>Tilde Friends Issues</h1> `; | ||||||
| 			<h1>Tilde Friends Issues</h1> |  | ||||||
| 			<tf-id-picker id="picker"></tf-id-picker> |  | ||||||
| 		`; |  | ||||||
| 		if (this.selected) { | 		if (this.selected) { | ||||||
| 			return html` | 			return html` | ||||||
| 				${header} | 				${header} | ||||||
| 				<div> | 				<div> | ||||||
| 					<input type="button" value="Back" @click=${() => this.selected = undefined}></input> | 					<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>` : | 						this.selected.open | ||||||
| 						html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>`} | 							? 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> | ||||||
| 				<div>${new Date(this.selected.created).toLocaleString()}</div> | 				<div>${new Date(this.selected.created).toLocaleString()}</div> | ||||||
| 				<div>${this.selected.author}</div> | 				<div>${this.selected.author}</div> | ||||||
| 				<div>${this.selected.id}</div> | 				<div>${this.selected.id}</div> | ||||||
| 				<div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div> | 				<div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div> | ||||||
| 				${this.selected.updates.map(x => this.render_update(x))} | 				${this.selected.updates.map((x) => this.render_update(x))} | ||||||
| 				<tf-compose @tf-submit=${this.reply_to_issue}></tf-compose> | 				<tf-compose @tf-submit=${this.reply_to_issue}></tf-compose> | ||||||
| 			`; | 			`; | ||||||
| 		} else { | 		} else { | ||||||
| @@ -250,7 +238,7 @@ class TfIssuesAppElement extends LitElement { | |||||||
| 						<th>Title</th> | 						<th>Title</th> | ||||||
| 						<th>Date</th> | 						<th>Date</th> | ||||||
| 					</tr> | 					</tr> | ||||||
| 					${this.issues.map(x => this.render_issue_table_row(x))} | 					${this.issues.map((x) => this.render_issue_table_row(x))} | ||||||
| 				</table> | 				</table> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -1,20 +1,32 @@ | |||||||
| import * as linkify from './commonmark-linkify.js'; | import * as linkify from './commonmark-linkify.js'; | ||||||
|  |  | ||||||
| function image(node, entering) { | function image(node, entering) { | ||||||
| 	if (node.firstChild?.type === 'text' && | 	if ( | ||||||
| 		node.firstChild.literal.startsWith('video:')) { | 		node.firstChild?.type === 'text' && | ||||||
|  | 		node.firstChild.literal.startsWith('video:') | ||||||
|  | 	) { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>'); | 			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.lit('<source src="' + this.esc(node.destination) + '"></source>'); | ||||||
| 			this.disableTags += 1; | 			this.disableTags += 1; | ||||||
| 		} else { | 		} else { | ||||||
| 			this.disableTags -= 1; | 			this.disableTags -= 1; | ||||||
| 			this.lit('</video>'); | 			this.lit('</video>'); | ||||||
| 		} | 		} | ||||||
| 	} else if (node.firstChild?.type === 'text' && | 	} else if ( | ||||||
| 		node.firstChild.literal.startsWith('audio:')) { | 		node.firstChild?.type === 'text' && | ||||||
|  | 		node.firstChild.literal.startsWith('audio:') | ||||||
|  | 	) { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>'); | 			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.lit('<source src="' + this.esc(node.destination) + '"></source>'); | ||||||
| 			this.disableTags += 1; | 			this.disableTags += 1; | ||||||
| 		} else { | 		} else { | ||||||
| @@ -24,7 +36,11 @@ function image(node, entering) { | |||||||
| 	} else { | 	} else { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			if (this.disableTags === 0) { | 			if (this.disableTags === 0) { | ||||||
| 				this.lit('<div class="img_caption">' + this.esc(node.firstChild?.literal || node.destination) + '</div>'); | 				this.lit( | ||||||
|  | 					'<div class="img_caption">' + | ||||||
|  | 						this.esc(node.firstChild?.literal || node.destination) + | ||||||
|  | 						'</div>' | ||||||
|  | 				); | ||||||
| 				if (this.options.safe && potentiallyUnsafe(node.destination)) { | 				if (this.options.safe && potentiallyUnsafe(node.destination)) { | ||||||
| 					this.lit('<img src="" alt="'); | 					this.lit('<img src="" alt="'); | ||||||
| 				} else { | 				} else { | ||||||
| @@ -56,14 +72,20 @@ export function markdown(md) { | |||||||
| 		node = event.node; | 		node = event.node; | ||||||
| 		if (event.entering) { | 		if (event.entering) { | ||||||
| 			if (node.type == 'link') { | 			if (node.type == 'link') { | ||||||
| 				if (node.destination.startsWith('@') && | 				if ( | ||||||
| 					node.destination.endsWith('.ed25519')) { | 					node.destination.startsWith('@') && | ||||||
|  | 					node.destination.endsWith('.ed25519') | ||||||
|  | 				) { | ||||||
| 					node.destination = '#' + node.destination; | 					node.destination = '#' + node.destination; | ||||||
| 				} else if (node.destination.startsWith('%') && | 				} else if ( | ||||||
| 					node.destination.endsWith('.sha256')) { | 					node.destination.startsWith('%') && | ||||||
|  | 					node.destination.endsWith('.sha256') | ||||||
|  | 				) { | ||||||
| 					node.destination = '#' + node.destination; | 					node.destination = '#' + node.destination; | ||||||
| 				} else if (node.destination.startsWith('&') && | 				} else if ( | ||||||
| 					node.destination.endsWith('.sha256')) { | 					node.destination.startsWith('&') && | ||||||
|  | 					node.destination.endsWith('.sha256') | ||||||
|  | 				) { | ||||||
| 					node.destination = '/' + node.destination + '/view'; | 					node.destination = '/' + node.destination + '/view'; | ||||||
| 				} | 				} | ||||||
| 			} else if (node.type == 'image') { | 			} else if (node.type == 'image') { | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
| 	"type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
| 	"emoji": "📝", | 	"emoji": "📝", | ||||||
|   "previous": "&2hdIDbBrAg63T2X1MzdGSF7yiqHvlnfF0PnInQLp0DA=.sha256" | 	"previous": "&b//KqE4Vx6kOSBRODK1p/8wjOLKZJ+CBB5IkaBt5YsM=.sha256" | ||||||
| } | } | ||||||
| @@ -104,8 +104,7 @@ async function process_message(whoami, collection, message, kind, parent) { | |||||||
| 		if (!x) { | 		if (!x) { | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 		if (content.type !== kind || | 		if (content.type !== kind || (parent && content.parent !== parent)) { | ||||||
| 			(parent && content.parent !== parent)) { |  | ||||||
| 			return; | 			return; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -113,7 +112,10 @@ async function process_message(whoami, collection, message, kind, parent) { | |||||||
| 		if (content?.tombstone) { | 		if (content?.tombstone) { | ||||||
| 			delete collection[content.key]; | 			delete collection[content.key]; | ||||||
| 		} else { | 		} else { | ||||||
| 			collection[content.key] = Object.assign(collection[content.key] || {}, content); | 			collection[content.key] = Object.assign( | ||||||
|  | 				collection[content.key] || {}, | ||||||
|  | 				content | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
| 	} else { | 	} else { | ||||||
| 		collection[message.id] = Object.assign(content, {id: message.id}); | 		collection[message.id] = Object.assign(content, {id: message.id}); | ||||||
| @@ -125,20 +127,29 @@ tfrpc.register(async function collection(ids, kind, parent, max_rowid, data) { | |||||||
| 	let whoami = await ssb.getIdentities(); | 	let whoami = await ssb.getIdentities(); | ||||||
| 	data = data ?? {}; | 	data = data ?? {}; | ||||||
| 	let rowid = 0; | 	let rowid = 0; | ||||||
| 	await ssb.sqlAsync('SELECT MAX(rowid) AS rowid FROM messages', [], function(row) { | 	await ssb.sqlAsync( | ||||||
|  | 		'SELECT MAX(rowid) AS rowid FROM messages', | ||||||
|  | 		[], | ||||||
|  | 		function (row) { | ||||||
| 			rowid = row.rowid; | 			rowid = row.rowid; | ||||||
| 	}); | 		} | ||||||
|  | 	); | ||||||
| 	while (true) { | 	while (true) { | ||||||
| 		if (rowid == max_rowid) { | 		if (rowid == max_rowid) { | ||||||
| 			await new_message(); | 			await new_message(); | ||||||
| 			await ssb.sqlAsync('SELECT MAX(rowid) AS rowid FROM messages', [], function(row) { | 			await ssb.sqlAsync( | ||||||
|  | 				'SELECT MAX(rowid) AS rowid FROM messages', | ||||||
|  | 				[], | ||||||
|  | 				function (row) { | ||||||
| 					rowid = row.rowid; | 					rowid = row.rowid; | ||||||
| 			}); | 				} | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		let modified = false; | 		let modified = false; | ||||||
| 		let rows = []; | 		let rows = []; | ||||||
| 		await ssb.sqlAsync(` | 		await ssb.sqlAsync( | ||||||
|  | 			` | ||||||
| 			SELECT messages.id, author, content, timestamp | 			SELECT messages.id, author, content, timestamp | ||||||
| 			FROM messages | 			FROM messages | ||||||
| 			JOIN json_each(?1) AS id ON messages.author = id.value | 			JOIN json_each(?1) AS id ON messages.author = id.value | ||||||
| @@ -152,7 +163,8 @@ tfrpc.register(async function collection(ids, kind, parent, max_rowid, data) { | |||||||
| 			[JSON.stringify(ids), max_rowid ?? -1, rowid, kind, parent], | 			[JSON.stringify(ids), max_rowid ?? -1, rowid, kind, parent], | ||||||
| 			function (row) { | 			function (row) { | ||||||
| 				rows.push(row); | 				rows.push(row); | ||||||
| 			}); | 			} | ||||||
|  | 		); | ||||||
| 		max_rowid = rowid; | 		max_rowid = rowid; | ||||||
| 		for (let row of rows) { | 		for (let row of rows) { | ||||||
| 			if (await process_message(whoami, data, row, kind, parent)) { | 			if (await process_message(whoami, data, row, kind, parent)) { | ||||||
|   | |||||||
| @@ -1,12 +1,14 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html> | <html> | ||||||
| 	<head> | 	<head> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body style="color: #fff"> | 	<body style="color: #fff"> | ||||||
| 		<tf-journal-app></tf-journal-app> | 		<tf-journal-app></tf-journal-app> | ||||||
| 		<script src="commonmark.min.js"></script> | 		<script src="commonmark.min.js"></script> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script src="tf-journal-app.js" type="module"></script> | 		<script src="tf-journal-app.js" type="module"></script> | ||||||
| 		<script src="tf-journal-entry.js" type="module"></script> | 		<script src="tf-journal-entry.js" type="module"></script> | ||||||
| 		<script src="tf-id-picker.js" type="module"></script> | 		<script src="tf-id-picker.js" type="module"></script> | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								apps/journal/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								apps/journal/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
											
										
									
								
							| @@ -19,15 +19,22 @@ class TfIdentityPickerElement extends LitElement { | |||||||
|  |  | ||||||
| 	changed(event) { | 	changed(event) { | ||||||
| 		this.selected = event.srcElement.value; | 		this.selected = event.srcElement.value; | ||||||
| 		this.dispatchEvent(new Event('change', { | 		this.dispatchEvent( | ||||||
|  | 			new Event('change', { | ||||||
| 				srcElement: this, | 				srcElement: this, | ||||||
| 		})); | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		return html` | 		return html` | ||||||
| 			<select @change=${this.changed} style="max-width: 100%"> | 			<select @change=${this.changed} style="max-width: 100%"> | ||||||
| 				${(this.ids ?? []).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)} | 				${(this.ids ?? []).map( | ||||||
|  | 					(id) => | ||||||
|  | 						html`<option ?selected=${id == this.selected} value=${id}> | ||||||
|  | 							${id} | ||||||
|  | 						</option>` | ||||||
|  | 				)} | ||||||
| 			</select> | 			</select> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -28,9 +28,14 @@ class TfJournalAppElement extends LitElement { | |||||||
| 	async read_journals() { | 	async read_journals() { | ||||||
| 		let max_rowid; | 		let max_rowid; | ||||||
| 		let journals; | 		let journals; | ||||||
| 		while (true) | 		while (true) { | ||||||
| 		{ | 			[max_rowid, journals] = await tfrpc.rpc.collection( | ||||||
| 			[max_rowid, journals] = await tfrpc.rpc.collection([this.whoami], 'journal-entry', undefined, max_rowid, journals); | 				[this.whoami], | ||||||
|  | 				'journal-entry', | ||||||
|  | 				undefined, | ||||||
|  | 				max_rowid, | ||||||
|  | 				journals | ||||||
|  | 			); | ||||||
| 			this.journals = Object.assign({}, journals); | 			this.journals = Object.assign({}, journals); | ||||||
| 			console.log('JOURNALS', this.journals); | 			console.log('JOURNALS', this.journals); | ||||||
| 		} | 		} | ||||||
| @@ -52,7 +57,11 @@ class TfJournalAppElement extends LitElement { | |||||||
| 		}; | 		}; | ||||||
| 		message.recps = [this.whoami]; | 		message.recps = [this.whoami]; | ||||||
| 		print(message); | 		print(message); | ||||||
| 		message = await tfrpc.rpc.encrypt(this.whoami, message.recps, JSON.stringify(message)); | 		message = await tfrpc.rpc.encrypt( | ||||||
|  | 			this.whoami, | ||||||
|  | 			message.recps, | ||||||
|  | 			JSON.stringify(message) | ||||||
|  | 		); | ||||||
| 		print(message); | 		print(message); | ||||||
| 		await tfrpc.rpc.appendMessage(this.whoami, message); | 		await tfrpc.rpc.appendMessage(this.whoami, message); | ||||||
| 	} | 	} | ||||||
| @@ -62,12 +71,17 @@ class TfJournalAppElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` | ||||||
| 			<div> | 			<div> | ||||||
| 				<tf-id-picker .ids=${this.ids} selected=${this.whoami} @change=${this.on_whoami_changed}></tf-id-picker> | 				<tf-id-picker | ||||||
|  | 					.ids=${this.ids} | ||||||
|  | 					selected=${this.whoami} | ||||||
|  | 					@change=${this.on_whoami_changed} | ||||||
|  | 				></tf-id-picker> | ||||||
| 			</div> | 			</div> | ||||||
| 			<tf-journal-entry | 			<tf-journal-entry | ||||||
| 				whoami=${this.whoami} | 				whoami=${this.whoami} | ||||||
| 				.journals=${this.journals} | 				.journals=${this.journals} | ||||||
| 				@publish=${this.on_journal_publish}></tf-journal-entry> | 				@publish=${this.on_journal_publish} | ||||||
|  | 			></tf-journal-entry> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -30,13 +30,15 @@ class TfJournalEntryElement extends LitElement { | |||||||
|  |  | ||||||
| 	async on_publish() { | 	async on_publish() { | ||||||
| 		console.log('publish', this.text); | 		console.log('publish', this.text); | ||||||
| 		this.dispatchEvent(new CustomEvent('publish', { | 		this.dispatchEvent( | ||||||
|  | 			new CustomEvent('publish', { | ||||||
| 				bubbles: true, | 				bubbles: true, | ||||||
| 				detail: { | 				detail: { | ||||||
| 					key: this.shadowRoot.getElementById('date_picker').value, | 					key: this.shadowRoot.getElementById('date_picker').value, | ||||||
| 					text: this.text, | 					text: this.text, | ||||||
| 				}, | 				}, | ||||||
| 		})); | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	back_dates(count) { | 	back_dates(count) { | ||||||
| @@ -63,19 +65,30 @@ class TfJournalEntryElement extends LitElement { | |||||||
| 		console.log('RENDER ENTRY', this.key, this.journals?.[this.key]); | 		console.log('RENDER ENTRY', this.key, this.journals?.[this.key]); | ||||||
| 		return html` | 		return html` | ||||||
| 			<select id="date_picker" @change=${this.on_date_change}> | 			<select id="date_picker" @change=${this.on_date_change}> | ||||||
| 				${this.back_dates(10).map(x => html` | 				${this.back_dates(10).map( | ||||||
| 					<option value=${x}>${x}</option> | 					(x) => html` <option value=${x}>${x}</option> ` | ||||||
| 				`)} | 				)} | ||||||
| 			</select> | 			</select> | ||||||
| 			<div style="display: inline-flex; flex-direction: row"> | 			<div style="display: inline-flex; flex-direction: row"> | ||||||
| 				<button ?disabled=${this.text == this.journals?.[this.key]?.text} @click=${this.on_publish}>Publish</button> | 				<button | ||||||
|  | 					?disabled=${this.text == this.journals?.[this.key]?.text} | ||||||
|  | 					@click=${this.on_publish} | ||||||
|  | 				> | ||||||
|  | 					Publish | ||||||
|  | 				</button> | ||||||
| 				<button @click=${this.on_discard}>Discard</button> | 				<button @click=${this.on_discard}>Discard</button> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div style="display: flex; flex-direction: row"> | 			<div style="display: flex; flex-direction: row"> | ||||||
| 				<textarea | 				<textarea | ||||||
| 					style="flex: 1 1; min-height: 10em" | 					style="flex: 1 1; min-height: 10em" | ||||||
| 					@input=${this.on_edit} .value=${this.text ?? this.journals?.[this.key]?.text ?? ''}></textarea> | 					@input=${this.on_edit} | ||||||
| 				<div style="flex: 1 1">${unsafeHTML(this.markdown(this.text ?? this.journals?.[this.key]?.text))}</div> | 					.value=${this.text ?? this.journals?.[this.key]?.text ?? ''} | ||||||
|  | 				></textarea> | ||||||
|  | 				<div style="flex: 1 1"> | ||||||
|  | 					${unsafeHTML( | ||||||
|  | 						this.markdown(this.text ?? this.journals?.[this.key]?.text) | ||||||
|  | 					)} | ||||||
|  | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								apps/room.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								apps/room.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,5 @@ | |||||||
|  | { | ||||||
|  | 	"type": "tildefriends-app", | ||||||
|  | 	"emoji": "🚪", | ||||||
|  | 	"previous": "&HXCdDG8gGYXElTyEFbg85jqa6lDXNL2ENPIA9UoJNbI=.sha256" | ||||||
|  | } | ||||||
							
								
								
									
										13
									
								
								apps/room/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										13
									
								
								apps/room/app.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,13 @@ | |||||||
|  | async function main() { | ||||||
|  | 	let host = core.url.match(/.*\/\/(.*?)\//)[1]; | ||||||
|  | 	let id = (await ssb.getServerIdentity()).substring(1); | ||||||
|  | 	let room = `net:${host}:${ssb.port}~shs:${id}:SSB+Room+SK3TLYC2T86EHQCUHBUHASCASE18JBV24=`; | ||||||
|  | 	await app.setDocument(` | ||||||
|  | 		<body style="color: #fff"> | ||||||
|  | 			<h1>Server</h1> | ||||||
|  | 			<div>The local server address is:</div> | ||||||
|  | 			<div><input type="text" readonly value="${room}" style="width: 100%"></input></div> | ||||||
|  | 		</body> | ||||||
|  | 	`); | ||||||
|  | } | ||||||
|  | main(); | ||||||
| @@ -1,4 +1,5 @@ | |||||||
| { | { | ||||||
| 	"type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
|   "emoji": "👟" | 	"emoji": "👟", | ||||||
|  | 	"previous": "&lYZRnT2UGQxXxYISbuaZewik9AuxBpcJumakwrePw5c=.sha256" | ||||||
| } | } | ||||||
| @@ -1,12 +1,14 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html style="color: #fff"> | <html style="color: #fff"> | ||||||
| 	<head> | 	<head> | ||||||
| 		<title>Tilde Friends</title> | 		<title>Tilde Friends</title> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body> | 	<body> | ||||||
| 		<tf-sneaker-app /> | 		<tf-sneaker-app /> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script src="filesaver.min.js"></script> | 		<script src="filesaver.min.js"></script> | ||||||
| 		<script src="jszip.min.js"></script> | 		<script src="jszip.min.js"></script> | ||||||
| 		<script src="script.js" type="module"></script> | 		<script src="script.js" type="module"></script> | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								apps/sneaker/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								apps/sneaker/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
											
										
									
								
							| @@ -19,7 +19,8 @@ class TfSneakerAppElement extends LitElement { | |||||||
|  |  | ||||||
| 	async search() { | 	async search() { | ||||||
| 		let q = this.renderRoot.getElementById('search').value; | 		let q = this.renderRoot.getElementById('search').value; | ||||||
| 		let result = await tfrpc.rpc.query(` | 		let result = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			SELECT messages.author AS id, json_extract(messages.content, '$.name') AS name | 			SELECT messages.author AS id, json_extract(messages.content, '$.name') AS name | ||||||
| 			FROM messages_fts(?) | 			FROM messages_fts(?) | ||||||
| 			JOIN messages ON messages.rowid = messages_fts.rowid | 			JOIN messages ON messages.rowid = messages_fts.rowid | ||||||
| @@ -31,15 +32,17 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 			HAVING MAX(messages.sequence) | 			HAVING MAX(messages.sequence) | ||||||
| 			ORDER BY COUNT(*) DESC | 			ORDER BY COUNT(*) DESC | ||||||
| 			`, | 			`, | ||||||
| 			[`"${q.replaceAll('"', '""')}"`]); | 			[`"${q.replaceAll('"', '""')}"`] | ||||||
| 		this.feeds = Object.fromEntries(result.map(x => [x.id, x.name])); | 		); | ||||||
|  | 		this.feeds = Object.fromEntries(result.map((x) => [x.id, x.name])); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	format_message(message) { | 	format_message(message) { | ||||||
|  | 		const k_flag_sequence_before_author = 1; | ||||||
| 		let out = { | 		let out = { | ||||||
| 			previous: message.previous ?? null, | 			previous: message.previous ?? null, | ||||||
| 		}; | 		}; | ||||||
| 		if (message.sequence_before_author) { | 		if (message.flags & k_flag_sequence_before_author) { | ||||||
| 			out.sequence = message.sequence; | 			out.sequence = message.sequence; | ||||||
| 			out.author = message.author; | 			out.author = message.author; | ||||||
| 		} else { | 		} else { | ||||||
| @@ -70,24 +73,104 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 			return true; | 			return true; | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		if (startsWith(data, [0xff, 0xd8, 0xff, 0xdb]) || | 		if ( | ||||||
| 			startsWith(data, [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]) || | 			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, 0xee]) || | ||||||
| 			startsWith(data, [0xff, 0xd8, 0xff, 0xe1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00])) { | 			startsWith(data, [ | ||||||
|  | 				0xff, | ||||||
|  | 				0xd8, | ||||||
|  | 				0xff, | ||||||
|  | 				0xe1, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				0x45, | ||||||
|  | 				0x78, | ||||||
|  | 				0x69, | ||||||
|  | 				0x66, | ||||||
|  | 				0x00, | ||||||
|  | 				0x00, | ||||||
|  | 			]) | ||||||
|  | 		) { | ||||||
| 			return '.jpg'; | 			return '.jpg'; | ||||||
| 		} else if (startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) { | 		} else if ( | ||||||
|  | 			startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]) | ||||||
|  | 		) { | ||||||
| 			return '.png'; | 			return '.png'; | ||||||
| 		} else if (startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || | 		} else if ( | ||||||
| 			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) { | 			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || | ||||||
|  | 			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61]) | ||||||
|  | 		) { | ||||||
| 			return '.gif'; | 			return '.gif'; | ||||||
| 		} else if (startsWith(data, [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50])) { | 		} else if ( | ||||||
|  | 			startsWith(data, [ | ||||||
|  | 				0x52, | ||||||
|  | 				0x49, | ||||||
|  | 				0x46, | ||||||
|  | 				0x46, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				0x57, | ||||||
|  | 				0x45, | ||||||
|  | 				0x42, | ||||||
|  | 				0x50, | ||||||
|  | 			]) | ||||||
|  | 		) { | ||||||
| 			return '.webp'; | 			return '.webp'; | ||||||
| 		} else if (startsWith(data, [0x3c, 0x73, 0x76, 0x67])) { | 		} else if (startsWith(data, [0x3c, 0x73, 0x76, 0x67])) { | ||||||
| 			return '.svg'; | 			return '.svg'; | ||||||
| 		} else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) { | 		} else if ( | ||||||
|  | 			startsWith(data, [ | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				null, | ||||||
|  | 				0x66, | ||||||
|  | 				0x74, | ||||||
|  | 				0x79, | ||||||
|  | 				0x70, | ||||||
|  | 				0x6d, | ||||||
|  | 				0x70, | ||||||
|  | 				0x34, | ||||||
|  | 				0x32, | ||||||
|  | 			]) | ||||||
|  | 		) { | ||||||
| 			return '.mp3'; | 			return '.mp3'; | ||||||
| 		} else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]) || | 		} else if ( | ||||||
| 			startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) { | 			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'; | 			return '.mp4'; | ||||||
| 		} else { | 		} else { | ||||||
| 			return '.bin'; | 			return '.bin'; | ||||||
| @@ -98,17 +181,34 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 		let all_messages = ''; | 		let all_messages = ''; | ||||||
| 		let sequence = -1; | 		let sequence = -1; | ||||||
| 		let messages_done = 0; | 		let messages_done = 0; | ||||||
| 		let messages_max = (await tfrpc.rpc.query('SELECT MAX(sequence) AS total FROM messages WHERE author = ?', [id]))[0].total; | 		let messages_max = ( | ||||||
|  | 			await tfrpc.rpc.query( | ||||||
|  | 				'SELECT MAX(sequence) AS total FROM messages WHERE author = ?', | ||||||
|  | 				[id] | ||||||
|  | 			) | ||||||
|  | 		)[0].total; | ||||||
| 		while (true) { | 		while (true) { | ||||||
| 			let messages = await tfrpc.rpc.query( | 			let messages = await tfrpc.rpc.query( | ||||||
| 					'SELECT * FROM messages WHERE author = ? AND SEQUENCE > ? ORDER BY sequence LIMIT 100', | 				` | ||||||
|  | 				SELECT author, id, sequence, timestamp, hash, json(content) AS content, signature, flags | ||||||
|  | 				FROM messages | ||||||
|  | 				WHERE author = ? AND SEQUENCE > ? | ||||||
|  | 				ORDER BY sequence LIMIT 100 | ||||||
|  | 				`, | ||||||
| 				[id, sequence] | 				[id, sequence] | ||||||
| 			); | 			); | ||||||
| 			if (messages?.length) { | 			if (messages?.length) { | ||||||
| 				all_messages += messages.map(x => JSON.stringify(this.format_message(x))).join('\n') + '\n'; | 				all_messages += | ||||||
|  | 					messages | ||||||
|  | 						.map((x) => JSON.stringify(this.format_message(x))) | ||||||
|  | 						.join('\n') + '\n'; | ||||||
| 				sequence = messages[messages.length - 1].sequence; | 				sequence = messages[messages.length - 1].sequence; | ||||||
| 				messages_done += messages.length; | 				messages_done += messages.length; | ||||||
| 				this.progress = {name: 'messages', value: messages_done, max: messages_max}; | 				this.progress = { | ||||||
|  | 					name: 'messages', | ||||||
|  | 					value: messages_done, | ||||||
|  | 					max: messages_max, | ||||||
|  | 				}; | ||||||
| 			} else { | 			} else { | ||||||
| 				break; | 				break; | ||||||
| 			} | 			} | ||||||
| @@ -122,7 +222,8 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 			FROM messages | 			FROM messages | ||||||
| 			JOIN messages_refs ON messages.id = messages_refs.message | 			JOIN messages_refs ON messages.id = messages_refs.message | ||||||
| 			WHERE messages.author = ? AND messages_refs.ref LIKE '&%.sha256'`, | 			WHERE messages.author = ? AND messages_refs.ref LIKE '&%.sha256'`, | ||||||
| 			[id]); | 			[id] | ||||||
|  | 		); | ||||||
| 		let blobs_done = 0; | 		let blobs_done = 0; | ||||||
| 		for (let row of blobs) { | 		for (let row of blobs) { | ||||||
| 			this.progress = {name: 'blobs', value: blobs_done, max: blobs.length}; | 			this.progress = {name: 'blobs', value: blobs_done, max: blobs.length}; | ||||||
| @@ -133,7 +234,10 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 				console.log(`Failed to get ${row.id}: ${e.message}`); | 				console.log(`Failed to get ${row.id}: ${e.message}`); | ||||||
| 			} | 			} | ||||||
| 			if (blob) { | 			if (blob) { | ||||||
| 				zip.file(`blob/classic/${this.sanitize(row.id)}${this.guess_ext(blob)}`, new Uint8Array(blob)); | 				zip.file( | ||||||
|  | 					`blob/classic/${this.sanitize(row.id)}${this.guess_ext(blob)}`, | ||||||
|  | 					new Uint8Array(blob) | ||||||
|  | 				); | ||||||
| 			} | 			} | ||||||
| 			blobs_done++; | 			blobs_done++; | ||||||
| 		} | 		} | ||||||
| @@ -181,7 +285,11 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 					continue; | 					continue; | ||||||
| 				} | 				} | ||||||
| 				let message = JSON.parse(line); | 				let message = JSON.parse(line); | ||||||
| 				this.progress = {name: 'messages', value: progress++, max: total_messages}; | 				this.progress = { | ||||||
|  | 					name: 'messages', | ||||||
|  | 					value: progress++, | ||||||
|  | 					max: total_messages, | ||||||
|  | 				}; | ||||||
| 				if (await tfrpc.rpc.store_message(message.value)) { | 				if (await tfrpc.rpc.store_message(message.value)) { | ||||||
| 					success.messages++; | 					success.messages++; | ||||||
| 				} | 				} | ||||||
| @@ -202,7 +310,13 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 		let progress; | 		let progress; | ||||||
| 		if (this.progress) { | 		if (this.progress) { | ||||||
| 			if (this.progress.max) { | 			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>`; | 				progress = html`<div> | ||||||
|  | 					<label for="progress">${this.progress.name}</label | ||||||
|  | 					><progress | ||||||
|  | 						value=${this.progress.value} | ||||||
|  | 						max=${this.progress.max} | ||||||
|  | 					></progress> | ||||||
|  | 				</div>`; | ||||||
| 			} else { | 			} else { | ||||||
| 				progress = html`<div><span>${this.progress.name}</span></div>`; | 				progress = html`<div><span>${this.progress.name}</span></div>`; | ||||||
| 			} | 			} | ||||||
| @@ -218,13 +332,17 @@ class TfSneakerAppElement extends LitElement { | |||||||
| 			<input type="text" id="search" @keypress=${this.keypress}></input> | 			<input type="text" id="search" @keypress=${this.keypress}></input> | ||||||
| 			<input type="button" value="Search Users" @click=${this.search}></input> | 			<input type="button" value="Search Users" @click=${this.search}></input> | ||||||
| 			<ul> | 			<ul> | ||||||
| 				${Object.entries(this.feeds).map(([id, name]) => html` | 				${Object.entries(this.feeds).map( | ||||||
|  | 					([id, name]) => html` | ||||||
| 						<li> | 						<li> | ||||||
| 						${this.progress ? undefined : html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`} | 							${this.progress | ||||||
|  | 								? undefined | ||||||
|  | 								: html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`} | ||||||
| 							${name} | 							${name} | ||||||
| 							<code style="color: #ccc">${id}</code> | 							<code style="color: #ccc">${id}</code> | ||||||
| 						</li> | 						</li> | ||||||
| 				`)} | 					` | ||||||
|  | 				)} | ||||||
| 			</ul> | 			</ul> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
| 	"type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
| 	"emoji": "🐌", | 	"emoji": "🐌", | ||||||
|   "previous": "&h+PXCrnUHtHHfKyUaLW+Y1dP/JpWwG9cbRNjxOCVqw0=.sha256" | 	"previous": "&sqnidhPsKb45FDw4m0/ZeDQgX3qxqw9MReaVP11Xk2M=.sha256" | ||||||
| } | } | ||||||
| @@ -100,6 +100,9 @@ tfrpc.register(async function try_decrypt(id, content) { | |||||||
| tfrpc.register(async function encrypt(id, recipients, content) { | tfrpc.register(async function encrypt(id, recipients, content) { | ||||||
| 	return await ssb.privateMessageEncrypt(id, recipients, content); | 	return await ssb.privateMessageEncrypt(id, recipients, content); | ||||||
| }); | }); | ||||||
|  | tfrpc.register(async function getActiveIdentity() { | ||||||
|  | 	return await ssb.getActiveIdentity(); | ||||||
|  | }); | ||||||
| ssb.addEventListener('broadcasts', async function () { | ssb.addEventListener('broadcasts', async function () { | ||||||
| 	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts()); | 	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts()); | ||||||
| }); | }); | ||||||
| @@ -107,9 +110,12 @@ ssb.addEventListener('broadcasts', async function() { | |||||||
| core.register('onConnectionsChanged', async function () { | core.register('onConnectionsChanged', async function () { | ||||||
| 	await tfrpc.rpc.set('connections', await ssb.connections()); | 	await tfrpc.rpc.set('connections', await ssb.connections()); | ||||||
| }); | }); | ||||||
|  | core.register('setActiveIdentity', async function (id) { | ||||||
|  | 	await tfrpc.rpc.set('identity', id); | ||||||
|  | }); | ||||||
|  |  | ||||||
| async function main() { | async function main() { | ||||||
| 	if (typeof(database) !== 'undefined') { | 	if (typeof database !== 'undefined') { | ||||||
| 		g_database = await database('ssb'); | 		g_database = await database('ssb'); | ||||||
| 	} | 	} | ||||||
| 	await app.setDocument(utf8Decode(await getFile('index.html'))); | 	await app.setDocument(utf8Decode(await getFile('index.html'))); | ||||||
|   | |||||||
| @@ -1,19 +1,23 @@ | |||||||
| function textNode(text) { | function textNode(text) { | ||||||
|   const node = new commonmark.Node("text", undefined); | 	const node = new commonmark.Node('text', undefined); | ||||||
| 	node.literal = text; | 	node.literal = text; | ||||||
| 	return node; | 	return node; | ||||||
| } | } | ||||||
|  |  | ||||||
| function linkNode(text, link) { | function linkNode(text, link) { | ||||||
|   const linkNode = new commonmark.Node("link", undefined); | 	const linkNode = new commonmark.Node('link', undefined); | ||||||
|  | 	if (link.startsWith('#')) { | ||||||
| 		linkNode.destination = `#q=${encodeURIComponent(link)}`; | 		linkNode.destination = `#q=${encodeURIComponent(link)}`; | ||||||
|  | 	} else { | ||||||
|  | 		linkNode.destination = link; | ||||||
|  | 	} | ||||||
| 	linkNode.appendChild(textNode(text)); | 	linkNode.appendChild(textNode(text)); | ||||||
| 	return linkNode; | 	return linkNode; | ||||||
| } | } | ||||||
|  |  | ||||||
| function splitMatches(text, regexp) { | function splitMatches(text, regexp) { | ||||||
| 	// Regexp must be sticky. | 	// Regexp must be sticky. | ||||||
|   regexp = new RegExp(regexp, "gm"); | 	regexp = new RegExp(regexp, 'gm'); | ||||||
|  |  | ||||||
| 	let i = 0; | 	let i = 0; | ||||||
| 	const result = []; | 	const result = []; | ||||||
| @@ -39,13 +43,13 @@ function splitMatches(text, regexp) { | |||||||
| 	return result; | 	return result; | ||||||
| } | } | ||||||
|  |  | ||||||
| const regex = new RegExp("(?<!\w)#[\\w-]+"); | const regex = new RegExp('(?:https?://[^ ]+[^ .,])|(?:(?<!\\w)#[\\w-]+)|(?:@[A-Za-z0-9+/]+=.ed25519)|(?:[%&][A-Za-z0-9+/]+=.sha256)'); | ||||||
|  |  | ||||||
| function split(textNodes) { | function split(textNodes) { | ||||||
|   const text = textNodes.map(n => n.literal).join(""); | 	const text = textNodes.map((n) => n.literal).join(''); | ||||||
| 	const parts = splitMatches(text, regex); | 	const parts = splitMatches(text, regex); | ||||||
|  |  | ||||||
|   return parts.map(part => { | 	return parts.map((part) => { | ||||||
| 		if (part[1]) { | 		if (part[1]) { | ||||||
| 			return linkNode(part[0], part[0]); | 			return linkNode(part[0], part[0]); | ||||||
| 		} else { | 		} else { | ||||||
| @@ -61,17 +65,17 @@ export function transform(parsed) { | |||||||
| 	let nodes = []; | 	let nodes = []; | ||||||
| 	while ((event = walker.next())) { | 	while ((event = walker.next())) { | ||||||
| 		const node = event.node; | 		const node = event.node; | ||||||
|     if (event.entering && node.type === "text") { | 		if (event.entering && node.type === 'text') { | ||||||
| 			nodes.push(node); | 			nodes.push(node); | ||||||
| 		} else { | 		} else { | ||||||
| 			if (nodes.length > 0) { | 			if (nodes.length > 0) { | ||||||
| 				split(nodes) | 				split(nodes) | ||||||
| 					.reverse() | 					.reverse() | ||||||
|           .forEach(newNode => { | 					.forEach((newNode) => { | ||||||
| 						nodes[0].insertAfter(newNode); | 						nodes[0].insertAfter(newNode); | ||||||
| 					}); | 					}); | ||||||
|  |  | ||||||
|         nodes.forEach(n => n.unlink()); | 				nodes.forEach((n) => n.unlink()); | ||||||
| 				nodes = []; | 				nodes = []; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| @@ -80,10 +84,10 @@ export function transform(parsed) { | |||||||
| 	if (nodes.length > 0) { | 	if (nodes.length > 0) { | ||||||
| 		split(nodes) | 		split(nodes) | ||||||
| 			.reverse() | 			.reverse() | ||||||
|       .forEach(newNode => { | 			.forEach((newNode) => { | ||||||
| 				nodes[0].insertAfter(newNode); | 				nodes[0].insertAfter(newNode); | ||||||
| 			}); | 			}); | ||||||
|     nodes.forEach(n => n.unlink()); | 		nodes.forEach((n) => n.unlink()); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	return parsed; | 	return parsed; | ||||||
|   | |||||||
| @@ -1,91 +0,0 @@ | |||||||
| 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,3 +1,5 @@ | |||||||
|  | import * as tfrpc from '/static/tfrpc.js'; | ||||||
|  |  | ||||||
| let g_emojis; | let g_emojis; | ||||||
|  |  | ||||||
| function get_emojis() { | function get_emojis() { | ||||||
| @@ -10,8 +12,24 @@ function get_emojis() { | |||||||
| 	}); | 	}); | ||||||
| } | } | ||||||
|  |  | ||||||
| export function picker(callback, anchor) { | async function get_recent(author) { | ||||||
| 	get_emojis().then(function(json) { | 	let recent = await tfrpc.rpc.query( | ||||||
|  | 		` | ||||||
|  | 		SELECT DISTINCT content ->> '$.vote.expression' AS value | ||||||
|  | 		FROM messages | ||||||
|  | 		WHERE author = ? AND | ||||||
|  | 		content ->> '$.type' = 'vote' | ||||||
|  | 		ORDER BY timestamp DESC LIMIT 10 | ||||||
|  | 	`, | ||||||
|  | 		[author] | ||||||
|  | 	); | ||||||
|  | 	return recent.map((x) => x.value); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | export async function picker(callback, anchor, author) { | ||||||
|  | 	let json = await get_emojis(); | ||||||
|  | 	let recent = await get_recent(author); | ||||||
|  |  | ||||||
| 	let div = document.createElement('div'); | 	let div = document.createElement('div'); | ||||||
| 	div.id = 'emoji_picker'; | 	div.id = 'emoji_picker'; | ||||||
| 	div.style.color = '#000'; | 	div.style.color = '#000'; | ||||||
| @@ -66,15 +84,51 @@ export function picker(callback, anchor) { | |||||||
| 		} | 		} | ||||||
| 		let search = input.value.toLowerCase(); | 		let search = input.value.toLowerCase(); | ||||||
| 		let any_at_all = false; | 		let any_at_all = false; | ||||||
|  | 		if (recent) { | ||||||
|  | 			let emoji_to_name = {}; | ||||||
|  | 			for (let row of Object.values(json)) { | ||||||
|  | 				for (let entry of Object.entries(row)) { | ||||||
|  | 					emoji_to_name[entry[1]] = entry[0]; | ||||||
|  | 				} | ||||||
|  | 			} | ||||||
|  | 			let header = document.createElement('div'); | ||||||
|  | 			header.appendChild(document.createTextNode('Recent')); | ||||||
|  | 			list.appendChild(header); | ||||||
|  | 			let any = false; | ||||||
|  | 			for (let entry of recent) { | ||||||
|  | 				if ( | ||||||
|  | 					search && | ||||||
|  | 					search.length && | ||||||
|  | 					(emoji_to_name[entry] || '').toLowerCase().indexOf(search) == -1 | ||||||
|  | 				) { | ||||||
|  | 					continue; | ||||||
|  | 				} | ||||||
|  | 				let emoji = document.createElement('span'); | ||||||
|  | 				const k_size = '1.25em'; | ||||||
|  | 				emoji.style.display = 'inline-block'; | ||||||
|  | 				emoji.style.overflow = 'hidden'; | ||||||
|  | 				emoji.style.cursor = 'pointer'; | ||||||
|  | 				emoji.onclick = chosen; | ||||||
|  | 				emoji.title = emoji_to_name[entry] || entry; | ||||||
|  | 				emoji.appendChild(document.createTextNode(entry)); | ||||||
|  | 				list.appendChild(emoji); | ||||||
|  | 				any = true; | ||||||
|  | 			} | ||||||
|  | 			if (!any) { | ||||||
|  | 				list.removeChild(header); | ||||||
|  | 			} | ||||||
|  | 		} | ||||||
| 		for (let row of Object.entries(json)) { | 		for (let row of Object.entries(json)) { | ||||||
| 			let header = document.createElement('div'); | 			let header = document.createElement('div'); | ||||||
| 			header.appendChild(document.createTextNode(row[0])); | 			header.appendChild(document.createTextNode(row[0])); | ||||||
| 			list.appendChild(header); | 			list.appendChild(header); | ||||||
| 			let any = false; | 			let any = false; | ||||||
| 			for (let entry of Object.entries(row[1])) { | 			for (let entry of Object.entries(row[1])) { | ||||||
| 					if (search && | 				if ( | ||||||
|  | 					search && | ||||||
| 					search.length && | 					search.length && | ||||||
| 						entry[0].toLowerCase().indexOf(search) == -1) { | 					entry[0].toLowerCase().indexOf(search) == -1 | ||||||
|  | 				) { | ||||||
| 					continue; | 					continue; | ||||||
| 				} | 				} | ||||||
| 				let emoji = document.createElement('span'); | 				let emoji = document.createElement('span'); | ||||||
| @@ -108,5 +162,4 @@ export function picker(callback, anchor) { | |||||||
| 	console.log('adding click'); | 	console.log('adding click'); | ||||||
| 	document.body.addEventListener('mousedown', cleanup); | 	document.body.addEventListener('mousedown', cleanup); | ||||||
| 	window.addEventListener('keydown', key_down); | 	window.addEventListener('keydown', key_down); | ||||||
| 	}); |  | ||||||
| } | } | ||||||
| @@ -1,8 +1,8 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html style="color: #fff"> | <html> | ||||||
| 	<head> | 	<head> | ||||||
| 		<title>Tilde Friends</title> | 		<title>Tilde Friends</title> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 		<link rel="stylesheet" href="tribute.css" /> | 		<link rel="stylesheet" href="tribute.css" /> | ||||||
| 		<style> | 		<style> | ||||||
| 			.tribute-container { | 			.tribute-container { | ||||||
| @@ -10,12 +10,14 @@ | |||||||
| 			} | 			} | ||||||
| 		</style> | 		</style> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body style="background-color: #223a5e"> | 	<body style="margin: 0; padding: 0"> | ||||||
| 		<tf-app class="w3-deep-purple"/> | 		<tf-app></tf-app> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<tf-reactions-modal id="reactions_modal"></tf-reactions-modal> | ||||||
|  | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script src="filesaver.min.js"></script> | 		<script src="filesaver.min.js"></script> | ||||||
| 		<script src="commonmark.min.js"></script> | 		<script src="commonmark.min.js"></script> | ||||||
| 		<script src="commonmark-linkify.js" type="module"></script> |  | ||||||
| 		<script src="commonmark-hashtag.js" type="module"></script> | 		<script src="commonmark-hashtag.js" type="module"></script> | ||||||
| 		<script src="script.js" type="module"></script> | 		<script src="script.js" type="module"></script> | ||||||
| 	</body> | 	</body> | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								apps/ssb/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								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
											
										
									
								
							| @@ -1,13 +1,13 @@ | |||||||
| import {LitElement, html} from './lit-all.min.js'; | import {LitElement, html} from './lit-all.min.js'; | ||||||
| import * as tfrpc from '/static/tfrpc.js'; | import * as tfrpc from '/static/tfrpc.js'; | ||||||
|  |  | ||||||
| import * as tf_id_picker from './tf-id-picker.js'; |  | ||||||
| import * as tf_app from './tf-app.js'; | import * as tf_app from './tf-app.js'; | ||||||
| import * as tf_message from './tf-message.js'; | import * as tf_message from './tf-message.js'; | ||||||
| import * as tf_user from './tf-user.js'; | import * as tf_user from './tf-user.js'; | ||||||
| import * as tf_compose from './tf-compose.js'; | import * as tf_compose from './tf-compose.js'; | ||||||
| import * as tf_news from './tf-news.js'; | import * as tf_news from './tf-news.js'; | ||||||
| import * as tf_profile from './tf-profile.js'; | import * as tf_profile from './tf-profile.js'; | ||||||
|  | import * as tf_reactions_modal from './tf-reactions-modal.js'; | ||||||
| import * as tf_tab_mentions from './tf-tab-mentions.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 from './tf-tab-news.js'; | ||||||
| import * as tf_tab_news_feed from './tf-tab-news-feed.js'; | import * as tf_tab_news_feed from './tf-tab-news-feed.js'; | ||||||
|   | |||||||
| @@ -34,9 +34,13 @@ class TfElement extends LitElement { | |||||||
| 		this.users = {}; | 		this.users = {}; | ||||||
| 		this.loaded = false; | 		this.loaded = false; | ||||||
| 		this.tags = []; | 		this.tags = []; | ||||||
| 		tfrpc.rpc.getBroadcasts().then(b => { self.broadcasts = b || []; }); | 		tfrpc.rpc.getBroadcasts().then((b) => { | ||||||
| 		tfrpc.rpc.getConnections().then(c => { self.connections = c || []; }); | 			self.broadcasts = b || []; | ||||||
| 		tfrpc.rpc.getHash().then(hash => self.set_hash(hash)); | 		}); | ||||||
|  | 		tfrpc.rpc.getConnections().then((c) => { | ||||||
|  | 			self.connections = c || []; | ||||||
|  | 		}); | ||||||
|  | 		tfrpc.rpc.getHash().then((hash) => self.set_hash(hash)); | ||||||
| 		tfrpc.register(function hashChanged(hash) { | 		tfrpc.register(function hashChanged(hash) { | ||||||
| 			self.set_hash(hash); | 			self.set_hash(hash); | ||||||
| 		}); | 		}); | ||||||
| @@ -48,13 +52,15 @@ class TfElement extends LitElement { | |||||||
| 				self.broadcasts = value; | 				self.broadcasts = value; | ||||||
| 			} else if (name === 'connections') { | 			} else if (name === 'connections') { | ||||||
| 				self.connections = value; | 				self.connections = value; | ||||||
|  | 			} else if (name === 'identity') { | ||||||
|  | 				self.whoami = value; | ||||||
| 			} | 			} | ||||||
| 		}); | 		}); | ||||||
| 		this.initial_load(); | 		this.initial_load(); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async initial_load() { | 	async initial_load() { | ||||||
| 		let whoami = await tfrpc.rpc.localStorageGet('whoami'); | 		let whoami = await tfrpc.rpc.getActiveIdentity(); | ||||||
| 		let ids = (await tfrpc.rpc.getIdentities()) || []; | 		let ids = (await tfrpc.rpc.getIdentities()) || []; | ||||||
| 		this.whoami = whoami ?? (ids.length ? ids[0] : undefined); | 		this.whoami = whoami ?? (ids.length ? ids[0] : undefined); | ||||||
| 		this.ids = ids; | 		this.ids = ids; | ||||||
| @@ -86,9 +92,14 @@ class TfElement extends LitElement { | |||||||
| 				last_row_id: 0, | 				last_row_id: 0, | ||||||
| 			}; | 			}; | ||||||
| 		} | 		} | ||||||
| 		let max_row_id = (await tfrpc.rpc.query(` | 		let max_row_id = ( | ||||||
|  | 			await tfrpc.rpc.query( | ||||||
|  | 				` | ||||||
| 			SELECT MAX(rowid) AS max_row_id FROM messages | 			SELECT MAX(rowid) AS max_row_id FROM messages | ||||||
| 		`, []))[0].max_row_id; | 		`, | ||||||
|  | 				[] | ||||||
|  | 			) | ||||||
|  | 		)[0].max_row_id; | ||||||
| 		for (let id of Object.keys(cache.about)) { | 		for (let id of Object.keys(cache.about)) { | ||||||
| 			if (ids.indexOf(id) == -1) { | 			if (ids.indexOf(id) == -1) { | ||||||
| 				delete cache.about[id]; | 				delete cache.about[id]; | ||||||
| @@ -98,7 +109,7 @@ class TfElement extends LitElement { | |||||||
| 		let abouts = await tfrpc.rpc.query( | 		let abouts = await tfrpc.rpc.query( | ||||||
| 			` | 			` | ||||||
| 				SELECT | 				SELECT | ||||||
| 					messages.* | 					messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 				FROM | 				FROM | ||||||
| 					messages, | 					messages, | ||||||
| 					json_each(?1) AS following | 					json_each(?1) AS following | ||||||
| @@ -109,7 +120,7 @@ class TfElement extends LitElement { | |||||||
| 					json_extract(messages.content, '$.type') = 'about' | 					json_extract(messages.content, '$.type') = 'about' | ||||||
| 				UNION | 				UNION | ||||||
| 				SELECT | 				SELECT | ||||||
| 					messages.* | 					messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 				FROM | 				FROM | ||||||
| 					messages, | 					messages, | ||||||
| 					json_each(?2) AS following | 					json_each(?2) AS following | ||||||
| @@ -120,17 +131,21 @@ class TfElement extends LitElement { | |||||||
| 				ORDER BY messages.author, messages.sequence | 				ORDER BY messages.author, messages.sequence | ||||||
| 			`, | 			`, | ||||||
| 			[ | 			[ | ||||||
| 				JSON.stringify(ids.filter(id => cache.about[id])), | 				JSON.stringify(ids.filter((id) => cache.about[id])), | ||||||
| 				JSON.stringify(ids.filter(id => !cache.about[id])), | 				JSON.stringify(ids.filter((id) => !cache.about[id])), | ||||||
| 				cache.last_row_id, | 				cache.last_row_id, | ||||||
| 				max_row_id, | 				max_row_id, | ||||||
| 			]); | 			] | ||||||
|  | 		); | ||||||
| 		for (let about of abouts) { | 		for (let about of abouts) { | ||||||
| 			let content = JSON.parse(about.content); | 			let content = JSON.parse(about.content); | ||||||
| 			if (content.about === about.author) { | 			if (content.about === about.author) { | ||||||
| 				delete content.type; | 				delete content.type; | ||||||
| 				delete content.about; | 				delete content.about; | ||||||
| 				cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content); | 				cache.about[about.author] = Object.assign( | ||||||
|  | 					cache.about[about.author] || {}, | ||||||
|  | 					content | ||||||
|  | 				); | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		cache.last_row_id = max_row_id; | 		cache.last_row_id = max_row_id; | ||||||
| @@ -145,15 +160,13 @@ class TfElement extends LitElement { | |||||||
| 	async fetch_new_message(id) { | 	async fetch_new_message(id) { | ||||||
| 		let messages = await tfrpc.rpc.query( | 		let messages = await tfrpc.rpc.query( | ||||||
| 			` | 			` | ||||||
| 				SELECT messages.* | 				SELECT messages.id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||||
| 				FROM messages | 				FROM messages | ||||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | 				JOIN json_each(?) AS following ON messages.author = following.value | ||||||
| 				WHERE messages.id = ? | 				WHERE messages.id = ? | ||||||
| 			`, | 			`, | ||||||
| 			[ | 			[JSON.stringify(this.following), id] | ||||||
| 				JSON.stringify(this.following), | 		); | ||||||
| 				id, |  | ||||||
| 			]); |  | ||||||
| 		if (messages && messages.length) { | 		if (messages && messages.length) { | ||||||
| 			this.unread = [...this.unread, ...messages]; | 			this.unread = [...this.unread, ...messages]; | ||||||
| 			this.unread = this.unread.slice(this.unread.length - 1024); | 			this.unread = this.unread.slice(this.unread.length - 1024); | ||||||
| @@ -173,7 +186,7 @@ class TfElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	async create_identity() { | 	async create_identity() { | ||||||
| 		if (confirm("Are you sure you want to create a new identity?")) { | 		if (confirm('Are you sure you want to create a new identity?')) { | ||||||
| 			await tfrpc.rpc.createIdentity(); | 			await tfrpc.rpc.createIdentity(); | ||||||
| 			this.ids = (await tfrpc.rpc.getIdentities()) || []; | 			this.ids = (await tfrpc.rpc.getIdentities()) || []; | ||||||
| 			if (this.ids && !this.whoami) { | 			if (this.ids && !this.whoami) { | ||||||
| @@ -182,20 +195,12 @@ class TfElement extends LitElement { | |||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_id_picker() { |  | ||||||
| 		return html` |  | ||||||
| 			<div style="display: flex; gap: 8px"> |  | ||||||
| 				<tf-id-picker id="picker" style="flex: 1 1 auto" selected=${this.whoami} .ids=${this.ids} .users=${this.users} @change=${this._handle_whoami_changed}></tf-id-picker> |  | ||||||
| 				<button class="w3-button w3-dark-grey w3-border" style="flex: 0 0 auto" @click=${this.create_identity} id="create_identity">Create Identity</button> |  | ||||||
| 			</div> |  | ||||||
| 		`; |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	async load_recent_tags() { | 	async load_recent_tags() { | ||||||
| 		let start = new Date(); | 		let start = new Date(); | ||||||
| 		this.tags = await tfrpc.rpc.query(` | 		this.tags = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			WITH | 			WITH | ||||||
| 				recent AS (SELECT id, content FROM messages | 				recent AS (SELECT id, json(content) AS content FROM messages | ||||||
| 					WHERE messages.timestamp > ? AND json_extract(content, '$.type') = 'post' | 					WHERE messages.timestamp > ? AND json_extract(content, '$.type') = 'post' | ||||||
| 					ORDER BY timestamp DESC LIMIT 1024), | 					ORDER BY timestamp DESC LIMIT 1024), | ||||||
| 				recent_channels AS (SELECT recent.id, '#' || json_extract(content, '$.channel') AS tag | 				recent_channels AS (SELECT recent.id, '#' || json_extract(content, '$.channel') AS tag | ||||||
| @@ -207,7 +212,9 @@ class TfElement extends LitElement { | |||||||
| 				combined AS (SELECT id, tag FROM recent_channels UNION ALL SELECT id, tag FROM recent_mentions), | 				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) | 				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 | 			SELECT tag, COUNT(*) AS count FROM by_message GROUP BY tag ORDER BY count DESC LIMIT 10 | ||||||
| 		`, [new Date() - 7 * 24 * 60 * 60 * 1000]); | 		`, | ||||||
|  | 			[new Date() - 7 * 24 * 60 * 60 * 1000] | ||||||
|  | 		); | ||||||
| 		console.log('tags took', (new Date() - start) / 1000.0, 'seconds'); | 		console.log('tags took', (new Date() - start) / 1000.0, 'seconds'); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -227,7 +234,15 @@ class TfElement extends LitElement { | |||||||
| 			by_count.push({count: v.of, id: id}); | 			by_count.push({count: v.of, id: id}); | ||||||
| 		} | 		} | ||||||
| 		console.log(by_count.sort((x, y) => y.count - x.count).slice(0, 20)); | 		console.log(by_count.sort((x, y) => y.count - x.count).slice(0, 20)); | ||||||
|  | 		let start_time = new Date(); | ||||||
| 		users = await this.fetch_about(Object.keys(following).sort(), users); | 		users = await this.fetch_about(Object.keys(following).sort(), users); | ||||||
|  | 		console.log( | ||||||
|  | 			'about took', | ||||||
|  | 			(new Date() - start_time) / 1000.0, | ||||||
|  | 			'seconds for', | ||||||
|  | 			Object.keys(users).length, | ||||||
|  | 			'users' | ||||||
|  | 		); | ||||||
| 		this.following = Object.keys(following); | 		this.following = Object.keys(following); | ||||||
| 		this.users = users; | 		this.users = users; | ||||||
| 		await tags; | 		await tags; | ||||||
| @@ -241,23 +256,53 @@ class TfElement extends LitElement { | |||||||
| 		let users = this.users; | 		let users = this.users; | ||||||
| 		if (this.tab === 'news') { | 		if (this.tab === 'news') { | ||||||
| 			return html` | 			return html` | ||||||
| 				<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> | 				<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') { | 		} else if (this.tab === 'connections') { | ||||||
| 			return html` | 			return html` | ||||||
| 				<tf-tab-connections .users=${this.users} .connections=${this.connections} .broadcasts=${this.broadcasts}></tf-tab-connections> | 				<tf-tab-connections | ||||||
|  | 					.users=${this.users} | ||||||
|  | 					.connections=${this.connections} | ||||||
|  | 					.broadcasts=${this.broadcasts} | ||||||
|  | 				></tf-tab-connections> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (this.tab === 'mentions') { | 		} else if (this.tab === 'mentions') { | ||||||
| 			return html` | 			return html` | ||||||
| 				<tf-tab-mentions .following=${this.following} whoami=${this.whoami} .users=${this.users}}></tf-tab-mentions> | 				<tf-tab-mentions | ||||||
|  | 					.following=${this.following} | ||||||
|  | 					whoami=${this.whoami} | ||||||
|  | 					.users="${this.users}}" | ||||||
|  | 				></tf-tab-mentions> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (this.tab === 'search') { | 		} else if (this.tab === 'search') { | ||||||
| 			return html` | 			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> | 				<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') { | 		} else if (this.tab === 'query') { | ||||||
| 			return html` | 			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> | 				<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> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -294,23 +339,44 @@ class TfElement extends LitElement { | |||||||
| 		}; | 		}; | ||||||
|  |  | ||||||
| 		let tabs = html` | 		let tabs = html` | ||||||
| 			<div class="w3-bar w3-black"> | 			<div class="w3-bar w3-theme-l1"> | ||||||
| 				${Object.entries(k_tabs).map(([k, v]) => html` | 				${Object.entries(k_tabs).map( | ||||||
| 				<button title=${v} class="w3-bar-item w3-padding-large w3-hover-gray tab ${self.tab == v ? 'w3-red' : 'w3-black'}" @click=${() => self.set_tab(v)}>${k}</button> | 					([k, v]) => html` | ||||||
| 				`)} | 						<button | ||||||
|  | 							title=${v} | ||||||
|  | 							class="w3-bar-item w3-padding-large w3-hover-theme tab ${self.tab == | ||||||
|  | 							v | ||||||
|  | 								? 'w3-theme-l2' | ||||||
|  | 								: 'w3-theme-l1'}" | ||||||
|  | 							@click=${() => self.set_tab(v)} | ||||||
|  | 						> | ||||||
|  | 							${k} | ||||||
|  | 						</button> | ||||||
|  | 					` | ||||||
|  | 				)} | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 		let contents = | 		let contents = !this.loaded | ||||||
| 				!this.loaded ? | 			? this.loading | ||||||
| 					this.loading ? | 				? html`<div class="w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge"> | ||||||
| 						html`<div>Loading...</div>` : | 					Loading... | ||||||
| 						html`<div>Select or create an identity.</div>` : | 				</div> | ||||||
| 					this.render_tab(); | 				${this.render_tab()}` | ||||||
|  | 				: html`<div>Select or create an identity.</div>` | ||||||
|  | 			: this.render_tab(); | ||||||
| 		return html` | 		return html` | ||||||
| 			${this.render_id_picker()} | 			<div | ||||||
|  | 				style="width: 100vw; min-height: 100vh; height: 100%" | ||||||
|  | 				class="w3-theme-dark" | ||||||
|  | 			> | ||||||
| 				${tabs} | 				${tabs} | ||||||
| 			${this.tags.map(x => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>`)} | 				<div style="padding: 8px"> | ||||||
|  | 					${this.tags.map( | ||||||
|  | 						(x) => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>` | ||||||
|  | 					)} | ||||||
| 					${contents} | 					${contents} | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | import {LitElement, html, unsafeHTML, live} from './lit-all.min.js'; | ||||||
| import * as tfutils from './tf-utils.js'; | import * as tfutils from './tf-utils.js'; | ||||||
| import * as tfrpc from '/static/tfrpc.js'; | import * as tfrpc from '/static/tfrpc.js'; | ||||||
| import {styles} from './tf-styles.js'; | import {styles} from './tf-styles.js'; | ||||||
| @@ -13,6 +13,7 @@ class TfComposeElement extends LitElement { | |||||||
| 			branch: {type: String}, | 			branch: {type: String}, | ||||||
| 			apps: {type: Object}, | 			apps: {type: Object}, | ||||||
| 			drafts: {type: Object}, | 			drafts: {type: Object}, | ||||||
|  | 			author: {type: String}, | ||||||
| 		}; | 		}; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -25,6 +26,7 @@ class TfComposeElement extends LitElement { | |||||||
| 		this.branch = undefined; | 		this.branch = undefined; | ||||||
| 		this.apps = undefined; | 		this.apps = undefined; | ||||||
| 		this.drafts = {}; | 		this.drafts = {}; | ||||||
|  | 		this.author = undefined; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	process_text(text) { | 	process_text(text) { | ||||||
| @@ -58,11 +60,13 @@ class TfComposeElement extends LitElement { | |||||||
| 					link: link, | 					link: link, | ||||||
| 				}; | 				}; | ||||||
| 			} | 			} | ||||||
| 			draft.mentions[link].name = name.startsWith('@') ? name.substring(1) : name; | 			draft.mentions[link].name = name.startsWith('@') | ||||||
|  | 				? name.substring(1) | ||||||
|  | 				: name; | ||||||
| 			updated = true; | 			updated = true; | ||||||
| 		} | 		} | ||||||
| 		if (updated) { | 		if (updated) { | ||||||
| 			this.requestUpdate(); | 			setTimeout(() => this.notify(draft), 0); | ||||||
| 		} | 		} | ||||||
| 		return tfutils.markdown(text); | 		return tfutils.markdown(text); | ||||||
| 	} | 	} | ||||||
| @@ -70,30 +74,31 @@ class TfComposeElement extends LitElement { | |||||||
| 	input(event) { | 	input(event) { | ||||||
| 		let edit = this.renderRoot.getElementById('edit'); | 		let edit = this.renderRoot.getElementById('edit'); | ||||||
| 		let preview = this.renderRoot.getElementById('preview'); | 		let preview = this.renderRoot.getElementById('preview'); | ||||||
| 		preview.innerHTML = this.process_text(edit.value); | 		preview.innerHTML = this.process_text(edit.innerText); | ||||||
| 		let content_warning = this.renderRoot.getElementById('content_warning'); | 		let content_warning = this.renderRoot.getElementById('content_warning'); | ||||||
| 		let content_warning_preview = this.renderRoot.getElementById('content_warning_preview'); | 		let content_warning_preview = this.renderRoot.getElementById( | ||||||
|  | 			'content_warning_preview' | ||||||
|  | 		); | ||||||
| 		if (content_warning && content_warning_preview) { | 		if (content_warning && content_warning_preview) { | ||||||
| 			content_warning_preview.innerText = content_warning.value; | 			content_warning_preview.innerText = content_warning.value; | ||||||
| 		} | 		} | ||||||
|  | 		let draft = this.get_draft(); | ||||||
|  | 		draft.text = edit.innerText; | ||||||
|  | 		draft.content_warning = content_warning?.innerText; | ||||||
|  | 		setTimeout(() => this.notify(draft), 0); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	notify(draft) { | 	notify(draft) { | ||||||
| 		this.dispatchEvent(new CustomEvent('tf-draft', { | 		this.dispatchEvent( | ||||||
|  | 			new CustomEvent('tf-draft', { | ||||||
| 				bubbles: true, | 				bubbles: true, | ||||||
| 				composed: true, | 				composed: true, | ||||||
| 				detail: { | 				detail: { | ||||||
| 					id: this.branch, | 					id: this.branch, | ||||||
| 				draft: draft | 					draft: draft, | ||||||
| 				}, | 				}, | ||||||
| 		})); | 			}) | ||||||
| 	} | 		); | ||||||
|  |  | ||||||
| 	change() { |  | ||||||
| 		let draft = this.get_draft(); |  | ||||||
| 		draft.text = this.renderRoot.getElementById('edit')?.value; |  | ||||||
| 		draft.content_warning = this.renderRoot.getElementById('content_warning')?.value; |  | ||||||
| 		this.notify(draft); |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	convert_to_format(buffer, type, mime_type) { | 	convert_to_format(buffer, type, mime_type) { | ||||||
| @@ -109,13 +114,17 @@ class TfComposeElement extends LitElement { | |||||||
| 				let context = canvas.getContext('2d'); | 				let context = canvas.getContext('2d'); | ||||||
| 				context.drawImage(img, 0, 0, canvas.width, canvas.height); | 				context.drawImage(img, 0, 0, canvas.width, canvas.height); | ||||||
| 				let data_url = canvas.toDataURL(mime_type); | 				let data_url = canvas.toDataURL(mime_type); | ||||||
| 				let result = atob(data_url.split(',')[1]).split('').map(x => x.charCodeAt(0)); | 				let result = atob(data_url.split(',')[1]) | ||||||
|  | 					.split('') | ||||||
|  | 					.map((x) => x.charCodeAt(0)); | ||||||
| 				resolve(result); | 				resolve(result); | ||||||
| 			}; | 			}; | ||||||
| 			img.onerror = function (event) { | 			img.onerror = function (event) { | ||||||
| 				reject(new Error('Failed to load image.')); | 				reject(new Error('Failed to load image.')); | ||||||
| 			}; | 			}; | ||||||
| 			let raw = Array.from(new Uint8Array(buffer)).map(b => String.fromCharCode(b)).join(''); | 			let raw = Array.from(new Uint8Array(buffer)) | ||||||
|  | 				.map((b) => String.fromCharCode(b)) | ||||||
|  | 				.join(''); | ||||||
| 			let original = `data:${type};base64,${btoa(raw)}`; | 			let original = `data:${type};base64,${btoa(raw)}`; | ||||||
| 			img.src = original; | 			img.src = original; | ||||||
| 		}); | 		}); | ||||||
| @@ -131,7 +140,11 @@ class TfComposeElement extends LitElement { | |||||||
| 				let best_buffer; | 				let best_buffer; | ||||||
| 				let best_type; | 				let best_type; | ||||||
| 				for (let format of ['image/png', 'image/jpeg', 'image/webp']) { | 				for (let format of ['image/png', 'image/jpeg', 'image/webp']) { | ||||||
| 					let test_buffer = await self.convert_to_format(buffer, file.type, format); | 					let test_buffer = await self.convert_to_format( | ||||||
|  | 						buffer, | ||||||
|  | 						file.type, | ||||||
|  | 						format | ||||||
|  | 					); | ||||||
| 					if (!best_buffer || test_buffer.length < best_buffer.length) { | 					if (!best_buffer || test_buffer.length < best_buffer.length) { | ||||||
| 						best_buffer = test_buffer; | 						best_buffer = test_buffer; | ||||||
| 						best_type = format; | 						best_type = format; | ||||||
| @@ -154,8 +167,7 @@ class TfComposeElement extends LitElement { | |||||||
| 				size: buffer.length ?? buffer.byteLength, | 				size: buffer.length ?? buffer.byteLength, | ||||||
| 			}; | 			}; | ||||||
| 			let edit = self.renderRoot.getElementById('edit'); | 			let edit = self.renderRoot.getElementById('edit'); | ||||||
| 			edit.value += `\n`; | 			edit.innerText += `\n`; | ||||||
| 			self.change(); |  | ||||||
| 			self.input(); | 			self.input(); | ||||||
| 		} catch (e) { | 		} catch (e) { | ||||||
| 			alert(e?.message); | 			alert(e?.message); | ||||||
| @@ -182,7 +194,7 @@ class TfComposeElement extends LitElement { | |||||||
| 		let edit = this.renderRoot.getElementById('edit'); | 		let edit = this.renderRoot.getElementById('edit'); | ||||||
| 		let message = { | 		let message = { | ||||||
| 			type: 'post', | 			type: 'post', | ||||||
| 			text: edit.value, | 			text: edit.innerText, | ||||||
| 		}; | 		}; | ||||||
| 		if (this.root || this.branch) { | 		if (this.root || this.branch) { | ||||||
| 			message.root = this.root; | 			message.root = this.root; | ||||||
| @@ -201,13 +213,17 @@ class TfComposeElement extends LitElement { | |||||||
| 			to = [...to]; | 			to = [...to]; | ||||||
| 			message.recps = to; | 			message.recps = to; | ||||||
| 			console.log('message is now', message); | 			console.log('message is now', message); | ||||||
| 			message = await tfrpc.rpc.encrypt(this.whoami, to, JSON.stringify(message)); | 			message = await tfrpc.rpc.encrypt( | ||||||
|  | 				this.whoami, | ||||||
|  | 				to, | ||||||
|  | 				JSON.stringify(message) | ||||||
|  | 			); | ||||||
| 			console.log('encrypted as', message); | 			console.log('encrypted as', message); | ||||||
| 		} | 		} | ||||||
| 		try { | 		try { | ||||||
| 			await tfrpc.rpc.appendMessage(this.whoami, message).then(function () { | 			await tfrpc.rpc.appendMessage(this.whoami, message).then(function () { | ||||||
| 				edit.value = ''; | 				edit.innerText = ''; | ||||||
| 				self.change(); | 				self.input(); | ||||||
| 				self.notify(undefined); | 				self.notify(undefined); | ||||||
| 				self.requestUpdate(); | 				self.requestUpdate(); | ||||||
| 			}); | 			}); | ||||||
| @@ -217,17 +233,11 @@ class TfComposeElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	discard() { | 	discard() { | ||||||
| 		let edit = this.renderRoot.getElementById('edit'); |  | ||||||
| 		edit.value = ''; |  | ||||||
| 		this.change(); |  | ||||||
| 		let preview = this.renderRoot.getElementById('preview'); |  | ||||||
| 		preview.innerHTML = ''; |  | ||||||
| 		this.notify(undefined); | 		this.notify(undefined); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	attach() { | 	attach() { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let edit = this.renderRoot.getElementById('edit'); |  | ||||||
| 		let input = document.createElement('input'); | 		let input = document.createElement('input'); | ||||||
| 		input.type = 'file'; | 		input.type = 'file'; | ||||||
| 		input.onchange = function (event) { | 		input.onchange = function (event) { | ||||||
| @@ -241,12 +251,15 @@ class TfComposeElement extends LitElement { | |||||||
| 		this.last_autocomplete = text; | 		this.last_autocomplete = text; | ||||||
| 		let results = []; | 		let results = []; | ||||||
| 		try { | 		try { | ||||||
| 			let rows = await tfrpc.rpc.query(` | 			let rows = await tfrpc.rpc.query( | ||||||
| 				SELECT messages.content FROM messages_fts(?) | 				` | ||||||
|  | 				SELECT json(messages.content) FROM messages_fts(?) | ||||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||||
| 				WHERE messages.content LIKE ? | 				WHERE messages.content LIKE ? | ||||||
| 				ORDER BY timestamp DESC LIMIT 10 | 				ORDER BY timestamp DESC LIMIT 10 | ||||||
| 			`, ['"' + text.replace('"', '""') + '"', `%%`]); | 			`, | ||||||
|  | 				['"' + text.replace('"', '""') + '"', `%%`] | ||||||
|  | 			); | ||||||
| 			for (let row of rows) { | 			for (let row of rows) { | ||||||
| 				for (let match of row.content.matchAll(/!\[([^\]]*)\]\((&.*?)\)/g)) { | 				for (let match of row.content.matchAll(/!\[([^\]]*)\]\((&.*?)\)/g)) { | ||||||
| 					if (match[1].toLowerCase().indexOf(text.toLowerCase()) != -1) { | 					if (match[1].toLowerCase().indexOf(text.toLowerCase()) != -1) { | ||||||
| @@ -262,19 +275,34 @@ class TfComposeElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	firstUpdated() { | 	firstUpdated() { | ||||||
|  | 		let values = Object.entries(this.users).map((x) => ({ | ||||||
|  | 			key: x[1].name ?? x[0], | ||||||
|  | 			value: x[0], | ||||||
|  | 		})); | ||||||
|  | 		if (this.author) { | ||||||
|  | 			values = [].concat( | ||||||
|  | 				[ | ||||||
|  | 					{ | ||||||
|  | 						key: this.users[this.author]?.name, | ||||||
|  | 						value: this.author, | ||||||
|  | 					}, | ||||||
|  | 				], | ||||||
|  | 				values | ||||||
|  | 			); | ||||||
|  | 		} | ||||||
| 		let tribute = new Tribute({ | 		let tribute = new Tribute({ | ||||||
| 			collection: [ | 			collection: [ | ||||||
| 				{ | 				{ | ||||||
| 					values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})), | 					values: values, | ||||||
| 					selectTemplate: function (item) { | 					selectTemplate: function (item) { | ||||||
| 						return `[@${item.original.key}](${item.original.value})`; | 						return item ? `[@${item.original.key}](${item.original.value})` : undefined; | ||||||
| 					}, | 					}, | ||||||
| 				}, | 				}, | ||||||
| 				{ | 				{ | ||||||
| 					trigger: '&', | 					trigger: '&', | ||||||
| 					values: this.autocomplete, | 					values: this.autocomplete, | ||||||
| 					selectTemplate: function (item) { | 					selectTemplate: function (item) { | ||||||
| 						return ``; | 						return item ? `` : undefined; | ||||||
| 					}, | 					}, | ||||||
| 				}, | 				}, | ||||||
| 			], | 			], | ||||||
| @@ -285,15 +313,18 @@ class TfComposeElement extends LitElement { | |||||||
| 	updated() { | 	updated() { | ||||||
| 		super.updated(); | 		super.updated(); | ||||||
| 		let edit = this.renderRoot.getElementById('edit'); | 		let edit = this.renderRoot.getElementById('edit'); | ||||||
| 		if (this.last_updated_text !== edit.value) { | 		if (this.last_updated_text !== edit.innerText) { | ||||||
| 			let preview = this.renderRoot.getElementById('preview'); | 			let preview = this.renderRoot.getElementById('preview'); | ||||||
| 			preview.innerHTML = this.process_text(edit.value); | 			preview.innerHTML = this.process_text(edit.innerText); | ||||||
| 			this.last_updated_text = edit.value; | 			this.last_updated_text = edit.innerText; | ||||||
| 		} | 		} | ||||||
| 		let encrypt = this.renderRoot.getElementById('encrypt_to'); | 		let encrypt = this.renderRoot.getElementById('encrypt_to'); | ||||||
| 		if (encrypt) { | 		if (encrypt) { | ||||||
| 			let tribute = new Tribute({ | 			let tribute = new Tribute({ | ||||||
| 				values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})), | 				values: Object.entries(this.users).map((x) => ({ | ||||||
|  | 					key: x[1].name, | ||||||
|  | 					value: x[0], | ||||||
|  | 				})), | ||||||
| 				selectTemplate: function (item) { | 				selectTemplate: function (item) { | ||||||
| 					return item.original.value; | 					return item.original.value; | ||||||
| 				}, | 				}, | ||||||
| @@ -305,23 +336,32 @@ class TfComposeElement extends LitElement { | |||||||
| 	remove_mention(id) { | 	remove_mention(id) { | ||||||
| 		let draft = this.get_draft(); | 		let draft = this.get_draft(); | ||||||
| 		delete draft.mentions[id]; | 		delete draft.mentions[id]; | ||||||
| 		this.notify(draft); | 		setTimeout(() => this.notify(), 0); | ||||||
| 		this.requestUpdate(); |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_mention(mention) { | 	render_mention(mention) { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` <div style="display: flex; flex-direction: row"> | ||||||
| 			<div style="display: flex; flex-direction: row"> |  | ||||||
| 			<div style="align-self: center; margin: 0.5em"> | 			<div style="align-self: center; margin: 0.5em"> | ||||||
| 					<button class="w3-button w3-dark-grey" title="Remove ${mention.name} mention" @click=${() => self.remove_mention(mention.link)}>🚮</button> | 				<button | ||||||
|  | 					class="w3-button w3-theme-d1" | ||||||
|  | 					title="Remove ${mention.name} mention" | ||||||
|  | 					@click=${() => self.remove_mention(mention.link)} | ||||||
|  | 				> | ||||||
|  | 					🚮 | ||||||
|  | 				</button> | ||||||
| 			</div> | 			</div> | ||||||
| 			<div style="display: flex; flex-direction: column"> | 			<div style="display: flex; flex-direction: column"> | ||||||
| 				<h3>${mention.name}</h3> | 				<h3>${mention.name}</h3> | ||||||
| 				<div style="padding-left: 1em"> | 				<div style="padding-left: 1em"> | ||||||
| 					${Object.entries(mention) | 					${Object.entries(mention) | ||||||
| 							.filter(x => x[0] != 'name') | 						.filter((x) => x[0] != 'name') | ||||||
| 							.map(x => html`<div><span style="font-weight: bold">${x[0]}</span>: ${x[1]}</div>`)} | 						.map( | ||||||
|  | 							(x) => | ||||||
|  | 								html`<div> | ||||||
|  | 									<span style="font-weight: bold">${x[0]}</span>: ${x[1]} | ||||||
|  | 								</div>` | ||||||
|  | 						)} | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		</div>`; | 		</div>`; | ||||||
| @@ -358,11 +398,20 @@ class TfComposeElement extends LitElement { | |||||||
| 		if (this.apps) { | 		if (this.apps) { | ||||||
| 			return html` | 			return html` | ||||||
| 				<div class="w3-card-4 w3-margin w3-padding"> | 				<div class="w3-card-4 w3-margin w3-padding"> | ||||||
| 					<select id="select" class="w3-select w3-dark-grey"> | 					<select id="select" class="w3-select w3-theme-d1"> | ||||||
| 						${Object.keys(self.apps).map(app => html`<option value=${app}>${app}</option>`)} | 						${Object.keys(self.apps).map( | ||||||
|  | 							(app) => html`<option value=${app}>${app}</option>` | ||||||
|  | 						)} | ||||||
| 					</select> | 					</select> | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${attach_selected_app}>Attach</button> | 					<button class="w3-button w3-theme-d1" @click=${attach_selected_app}> | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${() => this.apps = null}>Cancel</button> | 						Attach | ||||||
|  | 					</button> | ||||||
|  | 					<button | ||||||
|  | 						class="w3-button w3-theme-d1" | ||||||
|  | 						@click=${() => (this.apps = null)} | ||||||
|  | 					> | ||||||
|  | 						Cancel | ||||||
|  | 					</button> | ||||||
| 				</div> | 				</div> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| @@ -374,9 +423,16 @@ class TfComposeElement extends LitElement { | |||||||
| 			self.apps = await tfrpc.rpc.apps(); | 			self.apps = await tfrpc.rpc.apps(); | ||||||
| 		} | 		} | ||||||
| 		if (!this.apps) { | 		if (!this.apps) { | ||||||
| 			return html`<button class="w3-button w3-dark-grey" @click=${attach_app}>Attach App</button>`; | 			return html`<button class="w3-button w3-theme-d1" @click=${attach_app}> | ||||||
|  | 				Attach App | ||||||
|  | 			</button>`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return html`<button class="w3-button w3-dark-grey" @click=${() => this.apps = null}>Discard App</button>`; | 			return html`<button | ||||||
|  | 				class="w3-button w3-theme-d1" | ||||||
|  | 				@click=${() => (this.apps = null)} | ||||||
|  | 			> | ||||||
|  | 				Discard App | ||||||
|  | 			</button>`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -394,15 +450,15 @@ class TfComposeElement extends LitElement { | |||||||
| 			return html` | 			return html` | ||||||
| 				<div class="w3-container w3-padding"> | 				<div class="w3-container w3-padding"> | ||||||
| 					<p> | 					<p> | ||||||
| 						<input type="checkbox" class="w3-check w3-dark-grey" id="cw" @change=${() => self.set_content_warning(undefined)} checked="checked"></input> | 						<input type="checkbox" class="w3-check w3-theme-d1" id="cw" @change=${() => self.set_content_warning(undefined)} checked="checked"></input> | ||||||
| 						<label for="cw">CW</label> | 						<label for="cw">CW</label> | ||||||
| 					</p> | 					</p> | ||||||
| 					<input type="text" class="w3-input w3-border w3-dark-grey" id="content_warning" placeholder="Enter a content warning here." @input=${this.input} @change=${this.change} value=${draft.content_warning}></input> | 					<input type="text" class="w3-input w3-border w3-theme-d1" id="content_warning" placeholder="Enter a content warning here." @input=${this.input} @change=${this.change} value=${draft.content_warning}></input> | ||||||
| 				</div> | 				</div> | ||||||
| 			`; | 			`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return html` | 			return html` | ||||||
| 				<input type="checkbox" class="w3-check w3-dark-grey" id="cw" @change=${() => self.set_content_warning('')}></input> | 				<input type="checkbox" class="w3-check w3-theme-d1" id="cw" @change=${() => self.set_content_warning('')}></input> | ||||||
| 				<label for="cw">CW</label> | 				<label for="cw">CW</label> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| @@ -432,14 +488,16 @@ class TfComposeElement extends LitElement { | |||||||
| 			<div style="display: flex; flex-direction: row; width: 100%"> | 			<div style="display: flex; flex-direction: row; width: 100%"> | ||||||
| 				<label for="encrypt_to">🔐 To:</label> | 				<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="text" id="encrypt_to" style="display: flex; flex: 1 1" @input=${this.update_encrypt}></input> | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${() => this.set_encrypt(undefined)}>🚮</button> | 				<button class="w3-button w3-theme-d1" @click=${() => this.set_encrypt(undefined)}>🚮</button> | ||||||
| 			</div> | 			</div> | ||||||
| 			<ul> | 			<ul> | ||||||
| 				${draft.encrypt_to.map(x => html` | 				${draft.encrypt_to.map( | ||||||
|  | 					(x) => html` | ||||||
| 					<li> | 					<li> | ||||||
| 						<tf-user id=${x} .users=${this.users}></tf-user> | 						<tf-user id=${x} .users=${this.users}></tf-user> | ||||||
| 						<input type="button" class="w3-button w3-dark-grey" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter(id => id != x))}></input> | 						<input type="button" class="w3-button w3-theme-d1" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter((id) => id != x))}></input> | ||||||
| 					</li>`)} | 					</li>` | ||||||
|  | 				)} | ||||||
| 			</ul> | 			</ul> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| @@ -455,34 +513,58 @@ class TfComposeElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let draft = self.get_draft(); | 		let draft = self.get_draft(); | ||||||
| 		let content_warning = | 		let content_warning = | ||||||
| 			draft.content_warning !== undefined ? | 			draft.content_warning !== undefined | ||||||
| 			html`<div class="w3-panel w3-round-xlarge w3-blue"> | 				? html`<div class="w3-panel w3-round-xlarge w3-theme-d2"> | ||||||
| 						<p id="content_warning_preview">${draft.content_warning}</p> | 						<p id="content_warning_preview">${draft.content_warning}</p> | ||||||
| 			</div>` : | 					</div>` | ||||||
| 			undefined; | 				: undefined; | ||||||
| 		let encrypt = draft.encrypt_to !== undefined ? | 		let encrypt = | ||||||
| 			undefined : | 			draft.encrypt_to !== undefined | ||||||
| 			html`<button class="w3-button w3-dark-grey" @click=${() => this.set_encrypt([])}>🔐</button>`; | 				? undefined | ||||||
|  | 				: html`<button | ||||||
|  | 						class="w3-button w3-theme-d1" | ||||||
|  | 						@click=${() => this.set_encrypt([])} | ||||||
|  | 					> | ||||||
|  | 						🔐 | ||||||
|  | 					</button>`; | ||||||
| 		let result = html` | 		let result = html` | ||||||
| 			<div class="w3-card-4 w3-blue-grey w3-padding" style="box-sizing: border-box"> | 			<div | ||||||
|  | 				class="w3-card-4 w3-theme-d4 w3-padding-small" | ||||||
|  | 				style="box-sizing: border-box" | ||||||
|  | 			> | ||||||
| 				${this.render_encrypt()} | 				${this.render_encrypt()} | ||||||
| 				<div style="display: flex; flex-direction: row; width: 100%; gap: 4px"> | 				<div class="w3-container w3-padding-small"> | ||||||
| 					<div style="flex: 1 0 50%"> | 					<div class="w3-half"> | ||||||
| 						<p><textarea class="w3-input w3-dark-grey w3-border" style="resize: vertical" placeholder="Write a post here." id="edit" @input=${this.input} @change=${this.change} @paste=${this.paste}>${draft.text}</textarea></p> | 						<span | ||||||
|  | 							class="w3-input w3-theme-d1 w3-border" | ||||||
|  | 							style="resize: vertical; width: 100%; overflow: hidden; white-space: pre-wrap" | ||||||
|  | 							placeholder="Write a post here." | ||||||
|  | 							id="edit" | ||||||
|  | 							@input=${this.input} | ||||||
|  | 							@paste=${this.paste} | ||||||
|  | 							contenteditable | ||||||
|  | 							.innerText=${live(draft.text ?? '')} | ||||||
|  | 							></span> | ||||||
| 					</div> | 					</div> | ||||||
| 					<div style="flex: 1 0 50%"> | 					<div class="w3-half w3-padding"> | ||||||
| 						${content_warning} | 						${content_warning} | ||||||
| 						<div id="preview"></div> | 						<div id="preview"></div> | ||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</div> | ||||||
| 				${Object.values(draft.mentions || {}).map(x => self.render_mention(x))} | 				${Object.values(draft.mentions || {}).map((x) => | ||||||
| 				${this.render_attach_app()} | 					self.render_mention(x) | ||||||
| 				${this.render_content_warning()} | 				)} | ||||||
| 				<button class="w3-button w3-dark-grey" id="submit" @click=${this.submit}>Submit</button> | 				${this.render_attach_app()} ${this.render_content_warning()} | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${this.attach}>Attach</button> | 				<button class="w3-button w3-theme-d1" id="submit" @click=${this.submit}> | ||||||
| 				${this.render_attach_app_button()} | 					Submit | ||||||
| 				${encrypt} | 				</button> | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${this.discard}>Discard</button> | 				<button class="w3-button w3-theme-d1" @click=${this.attach}> | ||||||
|  | 					Attach | ||||||
|  | 				</button> | ||||||
|  | 				${this.render_attach_app_button()} ${encrypt} | ||||||
|  | 				<button class="w3-button w3-theme-d1" @click=${this.discard}> | ||||||
|  | 					Discard | ||||||
|  | 				</button> | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 		return result; | 		return result; | ||||||
|   | |||||||
| @@ -1,41 +0,0 @@ | |||||||
| import {LitElement, html} from './lit-all.min.js'; |  | ||||||
| import * as tfrpc from '/static/tfrpc.js'; |  | ||||||
| import {styles} from './tf-styles.js'; |  | ||||||
|  |  | ||||||
| /* |  | ||||||
| ** Provide a list of IDs, and this lets the user pick one. |  | ||||||
| */ |  | ||||||
| class TfIdentityPickerElement extends LitElement { |  | ||||||
| 	static get properties() { |  | ||||||
| 		return { |  | ||||||
| 			ids: {type: Array}, |  | ||||||
| 			selected: {type: String}, |  | ||||||
| 			users: {type: Object}, |  | ||||||
| 		}; |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	static styles = styles; |  | ||||||
|  |  | ||||||
| 	constructor() { |  | ||||||
| 		super(); |  | ||||||
| 		this.ids = []; |  | ||||||
| 		this.users = {}; |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	changed(event) { |  | ||||||
| 		this.selected = event.srcElement.value; |  | ||||||
| 		this.dispatchEvent(new Event('change', { |  | ||||||
| 			srcElement: this, |  | ||||||
| 		})); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	render() { |  | ||||||
| 		return html` |  | ||||||
| 			<select class="w3-select w3-dark-grey w3-padding w3-border" @change=${this.changed} style="max-width: 100%; overflow: hidden"> |  | ||||||
| 				${(this.ids ?? []).map(id => html`<option ?selected=${id == this.selected} value=${id}>${this.users[id]?.name ? (this.users[id]?.name + ' - ') : undefined}<small>${id}</small></option>`)} |  | ||||||
| 			</select> |  | ||||||
| 		`; |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
| customElements.define('tf-id-picker', TfIdentityPickerElement); |  | ||||||
| @@ -1,4 +1,4 @@ | |||||||
| import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | import {LitElement, html, render, unsafeHTML} from './lit-all.min.js'; | ||||||
| import * as tfrpc from '/static/tfrpc.js'; | import * as tfrpc from '/static/tfrpc.js'; | ||||||
| import * as tfutils from './tf-utils.js'; | import * as tfutils from './tf-utils.js'; | ||||||
| import * as emojis from './emojis.js'; | import * as emojis from './emojis.js'; | ||||||
| @@ -31,14 +31,33 @@ class TfMessageElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	show_reply() { | 	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, | 					encrypt_to: this.message?.decrypted?.recps, | ||||||
| 		}}}); | 				}, | ||||||
|  | 			}, | ||||||
|  | 		}); | ||||||
| 		this.dispatchEvent(event); | 		this.dispatchEvent(event); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	discard_reply() { | 	discard_reply() { | ||||||
| 		this.dispatchEvent(new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.id, draft: undefined}})); | 		this.dispatchEvent( | ||||||
|  | 			new CustomEvent('tf-draft', { | ||||||
|  | 				bubbles: true, | ||||||
|  | 				composed: true, | ||||||
|  | 				detail: {id: this.id, draft: undefined}, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	show_reactions() { | ||||||
|  | 		let modal = document.getElementById('reactions_modal'); | ||||||
|  | 		modal.users = this.users; | ||||||
|  | 		modal.votes = this.message?.votes || []; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_votes() { | 	render_votes() { | ||||||
| @@ -53,12 +72,21 @@ class TfMessageElement extends LitElement { | |||||||
| 				return expression; | 				return expression; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		return html`<div>${(this.message.votes || []).map( | 		if (this.message?.votes?.length) { | ||||||
| 			vote => html` | 			return html`<div class="w3-button" @click=${this.show_reactions}> | ||||||
| 				<span title="${this.users[vote.author]?.name ?? vote.author} ${new Date(vote.timestamp)}"> | 				${(this.message.votes || []).map( | ||||||
|  | 					(vote) => html` | ||||||
|  | 						<span | ||||||
|  | 							title="${this.users[vote.author]?.name ?? vote.author} ${new Date( | ||||||
|  | 								vote.timestamp | ||||||
|  | 							)}" | ||||||
|  | 						> | ||||||
| 							${normalize_expression(vote.content.vote.expression)} | 							${normalize_expression(vote.content.vote.expression)} | ||||||
| 						</span> | 						</span> | ||||||
| 			`)}</div>`; | 					` | ||||||
|  | 				)} | ||||||
|  | 			</div>`; | ||||||
|  | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_raw() { | 	render_raw() { | ||||||
| @@ -72,30 +100,40 @@ class TfMessageElement extends LitElement { | |||||||
| 			content: this.message?.content, | 			content: this.message?.content, | ||||||
| 			signature: this.message?.signature, | 			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) { | 	vote(emoji) { | ||||||
| 		let reaction = emoji; | 		let reaction = emoji; | ||||||
| 		let message = this.message.id; | 		let message = this.message.id; | ||||||
| 		if (confirm('Are you sure you want to react with ' + reaction + ' to ' + message + '?')) { | 		if ( | ||||||
| 			tfrpc.rpc.appendMessage( | 			confirm( | ||||||
| 				this.whoami, | 				'Are you sure you want to react with ' + | ||||||
| 				{ | 					reaction + | ||||||
|  | 					' to ' + | ||||||
|  | 					message + | ||||||
|  | 					'?' | ||||||
|  | 			) | ||||||
|  | 		) { | ||||||
|  | 			tfrpc.rpc | ||||||
|  | 				.appendMessage(this.whoami, { | ||||||
| 					type: 'vote', | 					type: 'vote', | ||||||
| 					vote: { | 					vote: { | ||||||
| 						link: message, | 						link: message, | ||||||
| 						value: 1, | 						value: 1, | ||||||
| 						expression: reaction, | 						expression: reaction, | ||||||
| 					}, | 					}, | ||||||
| 				}).catch(function(error) { | 				}) | ||||||
|  | 				.catch(function (error) { | ||||||
| 					alert(error?.message); | 					alert(error?.message); | ||||||
| 				}); | 				}); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	react(event) { | 	react(event) { | ||||||
| 		emojis.picker(x => this.vote(x)); | 		emojis.picker((x) => this.vote(x), null, this.whoami); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	show_image(link) { | 	show_image(link) { | ||||||
| @@ -129,7 +167,10 @@ class TfMessageElement extends LitElement { | |||||||
| 	body_click(event) { | 	body_click(event) { | ||||||
| 		if (event.srcElement.tagName == 'IMG') { | 		if (event.srcElement.tagName == 'IMG') { | ||||||
| 			this.show_image(event.srcElement.src); | 			this.show_image(event.srcElement.src); | ||||||
| 		} else if (event.srcElement.tagName == 'DIV' && event.srcElement.classList.contains('img_caption')) { | 		} else if ( | ||||||
|  | 			event.srcElement.tagName == 'DIV' && | ||||||
|  | 			event.srcElement.classList.contains('img_caption') | ||||||
|  | 		) { | ||||||
| 			let next = event.srcElement.nextSibling; | 			let next = event.srcElement.nextSibling; | ||||||
| 			if (next.style.display == 'block') { | 			if (next.style.display == 'block') { | ||||||
| 				next.style.display = 'none'; | 				next.style.display = 'none'; | ||||||
| @@ -140,50 +181,77 @@ class TfMessageElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_mention(mention) { | 	render_mention(mention) { | ||||||
| 		if (!mention?.link || typeof(mention.link) != 'string') { | 		if (!mention?.link || typeof mention.link != 'string') { | ||||||
| 			return html` <pre>${JSON.stringify(mention)}</pre>`; | 			return html` <pre>${JSON.stringify(mention)}</pre>`; | ||||||
| 		} else if (mention?.link?.startsWith('&') && | 		} else if ( | ||||||
| 			mention?.type?.startsWith('image/')) { | 			mention?.link?.startsWith('&') && | ||||||
|  | 			mention?.type?.startsWith('image/') | ||||||
|  | 		) { | ||||||
| 			return html` | 			return html` | ||||||
| 				<img src=${'/' + mention.link + '/view'} style="max-width: 128px; max-height: 128px" title=${mention.name} @click=${() => this.show_image('/' + mention.link + '/view')}> | 				<img | ||||||
|  | 					src=${'/' + mention.link + '/view'} | ||||||
|  | 					style="max-width: 128px; max-height: 128px" | ||||||
|  | 					title=${mention.name} | ||||||
|  | 					@click=${() => this.show_image('/' + mention.link + '/view')} | ||||||
|  | 				/> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (mention.link?.startsWith('&') && | 		} else if ( | ||||||
| 			mention.name?.startsWith('audio:')) { | 			mention.link?.startsWith('&') && | ||||||
|  | 			mention.name?.startsWith('audio:') | ||||||
|  | 		) { | ||||||
| 			return html` | 			return html` | ||||||
| 				<audio controls style="height: 32px"> | 				<audio controls style="height: 32px"> | ||||||
| 					<source src=${'/' + mention.link + '/view'}></source> | 					<source src=${'/' + mention.link + '/view'}></source> | ||||||
| 				</audio> | 				</audio> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (mention.link?.startsWith('&') && | 		} else if ( | ||||||
| 			mention.name?.startsWith('video:')) { | 			mention.link?.startsWith('&') && | ||||||
|  | 			mention.name?.startsWith('video:') | ||||||
|  | 		) { | ||||||
| 			return html` | 			return html` | ||||||
| 				<video controls style="max-height: 240px; max-width: 128px"> | 				<video controls style="max-height: 240px; max-width: 128px"> | ||||||
| 					<source src=${'/' + mention.link + '/view'}></source> | 					<source src=${'/' + mention.link + '/view'}></source> | ||||||
| 				</video> | 				</video> | ||||||
| 			`; | 			`; | ||||||
| 		} else if (mention.link?.startsWith('&') && | 		} else if ( | ||||||
| 			mention?.type === 'application/tildefriends') { | 			mention.link?.startsWith('&') && | ||||||
|  | 			mention?.type === 'application/tildefriends' | ||||||
|  | 		) { | ||||||
| 			return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`; | 			return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`; | ||||||
| 		} else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) { | 		} else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) { | ||||||
| 			return html` <a href=${'#' + encodeURIComponent(mention.link)}>${mention.name}</a>`; | 			return html` <a href=${'#' + encodeURIComponent(mention.link)} | ||||||
|  | 				>${mention.name}</a | ||||||
|  | 			>`; | ||||||
| 		} else if (mention.link?.startsWith('#')) { | 		} else if (mention.link?.startsWith('#')) { | ||||||
| 			return html` <a href=${'#q=' + encodeURIComponent(mention.link)}>${mention.link}</a>`; | 			return html` <a href=${'#q=' + encodeURIComponent(mention.link)} | ||||||
| 		} else if (Object.keys(mention).length == 2 && mention.link && mention.name) { | 				>${mention.link}</a | ||||||
|  | 			>`; | ||||||
|  | 		} else if ( | ||||||
|  | 			Object.keys(mention).length == 2 && | ||||||
|  | 			mention.link && | ||||||
|  | 			mention.name | ||||||
|  | 		) { | ||||||
| 			return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`; | 			return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return html` <pre style="white-space: pre-wrap">${JSON.stringify(mention, null, 2)}</pre>`; | 			return html` <pre style="white-space: pre-wrap"> | ||||||
|  | ${JSON.stringify(mention, null, 2)}</pre | ||||||
|  | 			>`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_mentions() { | 	render_mentions() { | ||||||
| 		let mentions = this.message?.content?.mentions || []; | 		let mentions = this.message?.content?.mentions || []; | ||||||
| 		mentions = mentions.filter(x => this.message?.content?.text?.indexOf(x.link) === -1); | 		mentions = mentions.filter( | ||||||
|  | 			(x) => this.message?.content?.text?.indexOf(x.link) === -1 | ||||||
|  | 		); | ||||||
| 		if (mentions.length) { | 		if (mentions.length) { | ||||||
| 			let self = this; | 			let self = this; | ||||||
| 			return html` | 			return html` | ||||||
| 				<fieldset style="background-color: rgba(0, 0, 0, 0.1); padding: 0.5em; border: 1px solid black"> | 				<fieldset | ||||||
|  | 					style="padding: 0.5em; border: 1px solid black" | ||||||
|  | 				> | ||||||
| 					<legend>Mentions</legend> | 					<legend>Mentions</legend> | ||||||
| 					${mentions.map(x => self.render_mention(x))} | 					${mentions.map((x) => self.render_mention(x))} | ||||||
| 				</fieldset> | 				</fieldset> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| @@ -194,28 +262,55 @@ class TfMessageElement extends LitElement { | |||||||
| 			return 0; | 			return 0; | ||||||
| 		} | 		} | ||||||
| 		let total = message.child_messages.length; | 		let total = message.child_messages.length; | ||||||
| 		for (let m of message.child_messages) | 		for (let m of message.child_messages) { | ||||||
| 		{ |  | ||||||
| 			total += this.total_child_messages(m); | 			total += this.total_child_messages(m); | ||||||
| 		} | 		} | ||||||
| 		return total; | 		return total; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	set_expanded(expanded, tag) { | 	set_expanded(expanded, tag) { | ||||||
| 		this.dispatchEvent(new CustomEvent('tf-expand', {bubbles: true, composed: true, detail: {id: (this.message.id || '') + (tag || ''), expanded: expanded}})); | 		this.dispatchEvent( | ||||||
|  | 			new CustomEvent('tf-expand', { | ||||||
|  | 				bubbles: true, | ||||||
|  | 				composed: true, | ||||||
|  | 				detail: {id: (this.message.id || '') + (tag || ''), expanded: expanded}, | ||||||
|  | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	toggle_expanded(tag) { | 	toggle_expanded(tag) { | ||||||
| 		this.set_expanded(!this.expanded[(this.message.id || '') + (tag || '')], tag); | 		this.set_expanded( | ||||||
|  | 			!this.expanded[(this.message.id || '') + (tag || '')], | ||||||
|  | 			tag | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_children() { | 	render_children() { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		if (this.message.child_messages?.length) { | 		if (this.message.child_messages?.length) { | ||||||
| 			if (!this.expanded[this.message.id]) { | 			if (!this.expanded[this.message.id]) { | ||||||
| 				return html`<button class="w3-button w3-dark-grey" @click=${() => self.set_expanded(true)}>+ ${this.total_child_messages(this.message) + ' More'}</button>`; | 				return html`<button | ||||||
|  | 					class="w3-button w3-theme-d1" | ||||||
|  | 					@click=${() => self.set_expanded(true)} | ||||||
|  | 				> | ||||||
|  | 					+ ${this.total_child_messages(this.message) + ' More'} | ||||||
|  | 				</button>`; | ||||||
| 			} else { | 			} else { | ||||||
| 				return html`<button class="w3-button w3-dark-grey" @click=${() => self.set_expanded(false)}>Collapse</button>${(this.message.child_messages || []).map(x => html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>`)}`; | 				return html`<button | ||||||
|  | 						class="w3-button w3-theme-d1" | ||||||
|  | 						@click=${() => self.set_expanded(false)} | ||||||
|  | 					> | ||||||
|  | 						Collapse</button | ||||||
|  | 					>${(this.message.child_messages || []).map( | ||||||
|  | 						(x) => | ||||||
|  | 							html`<tf-message | ||||||
|  | 								.message=${x} | ||||||
|  | 								whoami=${this.whoami} | ||||||
|  | 								.users=${this.users} | ||||||
|  | 								.drafts=${this.drafts} | ||||||
|  | 								.expanded=${this.expanded} | ||||||
|  | 							></tf-message>` | ||||||
|  | 					)}`; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -231,13 +326,12 @@ class TfMessageElement extends LitElement { | |||||||
| 		} | 		} | ||||||
| 		if (Array.isArray(content.mentions)) { | 		if (Array.isArray(content.mentions)) { | ||||||
| 			for (let mention of content.mentions) { | 			for (let mention of content.mentions) { | ||||||
| 				if (typeof mention?.link === 'string' && | 				if (typeof mention?.link === 'string' && mention.link.startsWith('#')) { | ||||||
| 					mention.link.startsWith('#')) { |  | ||||||
| 					channels.push(mention.link); | 					channels.push(mention.link); | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		return channels.map(x => html`<tf-tag tag=${x}></tf-tag>`); | 		return channels.map((x) => html`<tf-tag tag=${x}></tf-tag>`); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| @@ -245,57 +339,127 @@ class TfMessageElement extends LitElement { | |||||||
| 		if (this.message?.decrypted?.type == 'post') { | 		if (this.message?.decrypted?.type == 'post') { | ||||||
| 			content = this.message.decrypted; | 			content = this.message.decrypted; | ||||||
| 		} | 		} | ||||||
|  | 		let class_background = this.message?.decrypted | ||||||
|  | 			? 'w3-pale-red' | ||||||
|  | 			: 'w3-theme-d4'; | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let raw_button; | 		let raw_button; | ||||||
| 		switch (this.format) { | 		switch (this.format) { | ||||||
| 			case 'raw': | 			case 'raw': | ||||||
| 				if (content?.type == 'post' || content?.type == 'blog') { | 				if (content?.type == 'post' || content?.type == 'blog') { | ||||||
| 					raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'md'}>Markdown</button>`; | 					raw_button = html`<button | ||||||
|  | 						class="w3-button w3-theme-d1" | ||||||
|  | 						@click=${() => (self.format = 'md')} | ||||||
|  | 					> | ||||||
|  | 						Markdown | ||||||
|  | 					</button>`; | ||||||
| 				} else { | 				} else { | ||||||
| 					raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'message'}>Message</button>`;  | 					raw_button = html`<button | ||||||
|  | 						class="w3-button w3-theme-d1" | ||||||
|  | 						@click=${() => (self.format = 'message')} | ||||||
|  | 					> | ||||||
|  | 						Message | ||||||
|  | 					</button>`; | ||||||
| 				} | 				} | ||||||
| 				break; | 				break; | ||||||
| 			case 'md': | 			case 'md': | ||||||
| 				raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'message'}>Message</button>`; | 				raw_button = html`<button | ||||||
|  | 					class="w3-button w3-theme-d1" | ||||||
|  | 					@click=${() => (self.format = 'message')} | ||||||
|  | 				> | ||||||
|  | 					Message | ||||||
|  | 				</button>`; | ||||||
| 				break; | 				break; | ||||||
| 			case 'decrypted': | 			case 'decrypted': | ||||||
| 				raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'raw'}>Raw</button>`; | 				raw_button = html`<button | ||||||
|  | 					class="w3-button w3-theme-d1" | ||||||
|  | 					@click=${() => (self.format = 'raw')} | ||||||
|  | 				> | ||||||
|  | 					Raw | ||||||
|  | 				</button>`; | ||||||
| 				break; | 				break; | ||||||
| 			default: | 			default: | ||||||
| 				if (this.message.decrypted) { | 				if (this.message.decrypted) { | ||||||
| 					raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'decrypted'}>Decrypted</button>`; | 					raw_button = html`<button | ||||||
|  | 						class="w3-button w3-theme-d1" | ||||||
|  | 						@click=${() => (self.format = 'decrypted')} | ||||||
|  | 					> | ||||||
|  | 						Decrypted | ||||||
|  | 					</button>`; | ||||||
| 				} else { | 				} else { | ||||||
| 					raw_button = html`<button class="w3-button w3-dark-grey" @click=${() => self.format = 'raw'}>Raw</button>`; | 					raw_button = html`<button | ||||||
|  | 						class="w3-button w3-theme-d1" | ||||||
|  | 						@click=${() => (self.format = 'raw')} | ||||||
|  | 					> | ||||||
|  | 						Raw | ||||||
|  | 					</button>`; | ||||||
| 				} | 				} | ||||||
| 				break; | 				break; | ||||||
| 		} | 		} | ||||||
| 		function small_frame(inner) { | 		function small_frame(inner) { | ||||||
| 			let body; | 			let body; | ||||||
| 			return html` | 			return html` | ||||||
| 				<div class="w3-card-4" style="background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block; overflow-wrap: anywhere"> | 				<div | ||||||
|  | 					class="w3-card-4 w3-theme-d4 w3-border-theme" | ||||||
|  | 					style="margin-top: 8px; padding: 16px; display: inline-block; overflow-wrap: anywhere" | ||||||
|  | 				> | ||||||
| 					<tf-user id=${self.message.author} .users=${self.users}></tf-user> | 					<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> | 					<span style="padding-right: 8px" | ||||||
| 					${raw_button} | 						><a tfarget="_top" href=${'#' + self.message.id}>%</a> ${new Date( | ||||||
| 					${self.format == 'raw' ? self.render_raw() : inner} | 							self.message.timestamp | ||||||
|  | 						).toLocaleString()}</span | ||||||
|  | 					> | ||||||
|  | 					${raw_button} ${self.format == 'raw' ? self.render_raw() : inner} | ||||||
| 					${self.render_votes()} | 					${self.render_votes()} | ||||||
|  | 					${(self.message.child_messages || []).map( | ||||||
|  | 						(x) => html` | ||||||
|  | 							<tf-message | ||||||
|  | 								.message=${x} | ||||||
|  | 								whoami=${self.whoami} | ||||||
|  | 								.users=${self.users} | ||||||
|  | 								.drafts=${self.drafts} | ||||||
|  | 								.expanded=${self.expanded} | ||||||
|  | 							></tf-message> | ||||||
|  | 						` | ||||||
|  | 					)} | ||||||
| 				</div> | 				</div> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| 		if (this.message?.type === 'contact_group') { | 		if (this.message?.type === 'contact_group') { | ||||||
| 			return html` | 			return html` <div | ||||||
| 				<div class="w3-card-4" style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere"> | 				class="w3-card-4 w3-theme-d4 w3-border-theme" | ||||||
| 					${this.message.messages.map(x =>  | 				style="margin-top: 8px; padding: 16px; overflow-wrap: anywhere" | ||||||
| 						html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>` | 			> | ||||||
|  | 				${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>`; | 			</div>`; | ||||||
| 		} else if (this.message.placeholder) { | 		} else if (this.message.placeholder) { | ||||||
| 			return html` | 			return html` <div | ||||||
| 				<div class="w3-card-4" style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; overflow-wrap: anywhere"> | 				class="w3-card-4 w3-theme-d4 w3-border-theme" | ||||||
| 					<a target="_top" href=${'#' + this.message.id}>${this.message.id}</a> (placeholder) | 				style="margin-top: 8px; padding: 16px; overflow-wrap: anywhere" | ||||||
|  | 			> | ||||||
|  | 				<a target="_top" href=${'#' + this.message.id}>${this.message.id}</a> | ||||||
|  | 				(placeholder) | ||||||
| 				<div>${this.render_votes()}</div> | 				<div>${this.render_votes()}</div> | ||||||
| 					${(this.message.child_messages || []).map(x => html` | 				${(this.message.child_messages || []).map( | ||||||
| 						<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message> | 					(x) => html` | ||||||
| 					`)} | 						<tf-message | ||||||
|  | 							.message=${x} | ||||||
|  | 							whoami=${this.whoami} | ||||||
|  | 							.users=${this.users} | ||||||
|  | 							.drafts=${this.drafts} | ||||||
|  | 							.expanded=${this.expanded} | ||||||
|  | 						></tf-message> | ||||||
|  | 					` | ||||||
|  | 				)} | ||||||
| 			</div>`; | 			</div>`; | ||||||
| 		} else if (typeof (content?.type === 'string')) { | 		} else if (typeof (content?.type === 'string')) { | ||||||
| 			if (content.type == 'about') { | 			if (content.type == 'about') { | ||||||
| @@ -307,7 +471,7 @@ class TfMessageElement extends LitElement { | |||||||
| 				} | 				} | ||||||
| 				if (content.image !== undefined) { | 				if (content.image !== undefined) { | ||||||
| 					image = html` | 					image = html` | ||||||
| 						<div><img src=${'/' + (typeof(content.image?.link) == 'string' ? content.image.link : content.image) + '/view'} style="width: 256px; height: auto"></img></div> | 						<div><img src=${'/' + (typeof content.image?.link == 'string' ? content.image.link : content.image) + '/view'} style="width: 256px; height: auto"></img></div> | ||||||
| 					`; | 					`; | ||||||
| 				} | 				} | ||||||
| 				if (content.description !== undefined) { | 				if (content.description !== undefined) { | ||||||
| @@ -317,41 +481,52 @@ class TfMessageElement extends LitElement { | |||||||
| 						</div> | 						</div> | ||||||
| 					`; | 					`; | ||||||
| 				} | 				} | ||||||
| 				let update = content.about == this.message.author ? | 				let update = | ||||||
| 					html`<div style="font-weight: bold">Updated profile.</div>` : | 					content.about == this.message.author | ||||||
| 					html`<div style="font-weight: bold">Updated profile for <tf-user id=${content.about} .users=${this.users}></tf-user>.</div>`; | 						? html`<div style="font-weight: bold">Updated profile.</div>` | ||||||
| 				return small_frame(html` | 						: html`<div style="font-weight: bold"> | ||||||
| 					${update} | 								Updated profile for | ||||||
| 					${name} | 								<tf-user id=${content.about} .users=${this.users}></tf-user>. | ||||||
| 					${image} | 							</div>`; | ||||||
| 					${description} | 				return small_frame(html` ${update} ${name} ${image} ${description} `); | ||||||
| 				`); |  | ||||||
| 			} else if (content.type == 'contact') { | 			} else if (content.type == 'contact') { | ||||||
| 				return html` | 				return html` | ||||||
| 					<div> | 					<div> | ||||||
| 						<tf-user id=${this.message.author} .users=${this.users}></tf-user> | 						<tf-user id=${this.message.author} .users=${this.users}></tf-user> | ||||||
| 						is | 						is | ||||||
| 						${ | 						${content.blocking === true | ||||||
| 							content.blocking === true ? 'blocking' : | 							? 'blocking' | ||||||
| 							content.blocking === false ? 'no longer blocking' : | 							: content.blocking === false | ||||||
| 							content.following === true ? 'following' : | 								? 'no longer blocking' | ||||||
| 							content.following === false ? 'no longer following' : | 								: content.following === true | ||||||
| 							'?' | 									? 'following' | ||||||
| 						} | 									: content.following === false | ||||||
| 						<tf-user id=${this.message.content.contact} .users=${this.users}></tf-user> | 										? 'no longer following' | ||||||
|  | 										: '?'} | ||||||
|  | 						<tf-user | ||||||
|  | 							id=${this.message.content.contact} | ||||||
|  | 							.users=${this.users} | ||||||
|  | 						></tf-user> | ||||||
| 					</div> | 					</div> | ||||||
| 				`; | 				`; | ||||||
| 			} else if (content.type == 'post') { | 			} else if (content.type == 'post') { | ||||||
| 				let reply = (this.drafts[this.message?.id] !== undefined) ? html` | 				let reply = | ||||||
|  | 					this.drafts[this.message?.id] !== undefined | ||||||
|  | 						? html` | ||||||
| 								<tf-compose | 								<tf-compose | ||||||
| 									whoami=${this.whoami} | 									whoami=${this.whoami} | ||||||
| 									.users=${this.users} | 									.users=${this.users} | ||||||
| 						root=${this.message.content.root || this.message.id} | 									root=${content.root || this.message.id} | ||||||
| 									branch=${this.message.id} | 									branch=${this.message.id} | ||||||
| 									.drafts=${this.drafts} | 									.drafts=${this.drafts} | ||||||
| 						@tf-discard=${this.discard_reply}></tf-compose> | 									@tf-discard=${this.discard_reply} | ||||||
| 				` : html` | 									author=${this.message.author} | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${this.show_reply}>Reply</button> | 								></tf-compose> | ||||||
|  | 							` | ||||||
|  | 						: html` | ||||||
|  | 								<button class="w3-button w3-theme-d1" @click=${this.show_reply}> | ||||||
|  | 									Reply | ||||||
|  | 								</button> | ||||||
| 							`; | 							`; | ||||||
| 				let self = this; | 				let self = this; | ||||||
| 				let body; | 				let body; | ||||||
| @@ -360,32 +535,44 @@ class TfMessageElement extends LitElement { | |||||||
| 						body = this.render_raw(); | 						body = this.render_raw(); | ||||||
| 						break; | 						break; | ||||||
| 					case 'md': | 					case 'md': | ||||||
| 						body = html`<code style="white-space: pre-wrap; overflow-wrap: anywhere">${content.text}</code>`; | 						body = html`<code | ||||||
|  | 							style="white-space: pre-wrap; overflow-wrap: anywhere" | ||||||
|  | 							>${content.text}</code | ||||||
|  | 						>`; | ||||||
| 						break; | 						break; | ||||||
| 					case 'message': | 					case 'message': | ||||||
| 						body = unsafeHTML(tfutils.markdown(content.text)); | 						body = unsafeHTML(tfutils.markdown(content.text)); | ||||||
| 						break; | 						break; | ||||||
|  | 					case 'decrypted': | ||||||
|  | 						body = html`<pre | ||||||
|  | 							style="white-space: pre-wrap; overflow-wrap: anywhere" | ||||||
|  | 						> | ||||||
|  | ${JSON.stringify(content, null, 2)}</pre | ||||||
|  | 						>`; | ||||||
|  | 						break; | ||||||
| 				} | 				} | ||||||
| 				let content_warning = html` | 				let content_warning = html` | ||||||
| 					<div class="w3-panel w3-round-xlarge w3-blue" style="cursor: pointer" @click=${x => this.toggle_expanded(':cw')}><p>${content.contentWarning}</p></div> | 					<div | ||||||
|  | 						class="w3-panel w3-round-xlarge w3-theme-l4" | ||||||
|  | 						style="cursor: pointer" | ||||||
|  | 						@click=${(x) => this.toggle_expanded(':cw')} | ||||||
|  | 					> | ||||||
|  | 						<p>${content.contentWarning}</p> | ||||||
|  | 					</div> | ||||||
| 				`; | 				`; | ||||||
| 				let content_html = | 				let content_html = html` | ||||||
| 					html` |  | ||||||
| 					${this.render_channels()} | 					${this.render_channels()} | ||||||
| 					<div @click=${this.body_click}>${body}</div> | 					<div @click=${this.body_click}>${body}</div> | ||||||
| 					${this.render_mentions()} | 					${this.render_mentions()} | ||||||
| 				`; | 				`; | ||||||
| 				let payload = | 				let payload = content.contentWarning | ||||||
| 					content.contentWarning ? | 					? self.expanded[(this.message.id || '') + ':cw'] | ||||||
| 						self.expanded[(this.message.id || '') + ':cw'] ? | 						? html` ${content_warning} ${content_html} ` | ||||||
| 							html` | 						: content_warning | ||||||
| 								${content_warning} | 					: content_html; | ||||||
| 								${content_html} | 				let is_encrypted = this.message?.decrypted | ||||||
| 							` : | 					? html`<span style="align-self: center">🔓</span>` | ||||||
| 							content_warning : | 					: undefined; | ||||||
| 						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` | 				return html` | ||||||
| 					<style> | 					<style> | ||||||
| 						code { | 						code { | ||||||
| @@ -401,26 +588,34 @@ class TfMessageElement extends LitElement { | |||||||
| 							display: block; | 							display: block; | ||||||
| 						} | 						} | ||||||
| 					</style> | 					</style> | ||||||
| 					<div class="w3-card-4" style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px"> | 					<div | ||||||
|  | 						class="w3-card-4 ${class_background} w3-border-theme" | ||||||
|  | 						style="margin-top: 8px; padding: 16px" | ||||||
|  | 					> | ||||||
| 						<div style="display: flex; flex-direction: row"> | 						<div style="display: flex; flex-direction: row"> | ||||||
| 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | ||||||
| 							${is_encrypted} | 							${is_encrypted} | ||||||
| 							<span style="flex: 1"></span> | 							<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 style="padding-right: 8px" | ||||||
|  | 								><a target="_top" href=${'#' + self.message.id}>%</a> | ||||||
|  | 								${new Date(this.message.timestamp).toLocaleString()}</span | ||||||
|  | 							> | ||||||
| 							<span>${raw_button}</span> | 							<span>${raw_button}</span> | ||||||
| 						</div> | 						</div> | ||||||
| 						${payload} | 						${payload} ${this.render_votes()} | ||||||
| 						${this.render_votes()} |  | ||||||
| 						<p> | 						<p> | ||||||
| 							${reply} | 							${reply} | ||||||
| 							<button class="w3-button w3-dark-grey" @click=${this.react}>React</button> | 							<button class="w3-button w3-theme-d1" @click=${this.react}> | ||||||
|  | 								React | ||||||
|  | 							</button> | ||||||
| 						</p> | 						</p> | ||||||
| 						${this.render_children()} | 						${this.render_children()} | ||||||
| 					</div> | 					</div> | ||||||
| 				`; | 				`; | ||||||
| 			} else if (content.type === 'issue') { | 			} else if (content.type === 'issue') { | ||||||
| 				let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined; | 				let is_encrypted = this.message?.decrypted | ||||||
| 				let style_background = this.message?.decrypted ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)'; | 					? html`<span style="align-self: center">🔓</span>` | ||||||
|  | 					: undefined; | ||||||
| 				return html` | 				return html` | ||||||
| 					<style> | 					<style> | ||||||
| 						code { | 						code { | ||||||
| @@ -436,18 +631,25 @@ class TfMessageElement extends LitElement { | |||||||
| 							display: block; | 							display: block; | ||||||
| 						} | 						} | ||||||
| 					</style> | 					</style> | ||||||
| 					<div class="w3-card-4" style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px"> | 					<div | ||||||
|  | 						class="w3-card-4 ${class_background} w3-border-theme" | ||||||
|  | 						style="margin-top: 8px; padding: 16px" | ||||||
|  | 					> | ||||||
| 						<div style="display: flex; flex-direction: row"> | 						<div style="display: flex; flex-direction: row"> | ||||||
| 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | ||||||
| 							${is_encrypted} | 							${is_encrypted} | ||||||
| 							<span style="flex: 1"></span> | 							<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 style="padding-right: 8px" | ||||||
|  | 								><a target="_top" href=${'#' + self.message.id}>%</a> | ||||||
|  | 								${new Date(this.message.timestamp).toLocaleString()}</span | ||||||
|  | 							> | ||||||
| 							<span>${raw_button}</span> | 							<span>${raw_button}</span> | ||||||
| 						</div> | 						</div> | ||||||
| 						${content.text} | 						${content.text} ${this.render_votes()} | ||||||
| 						${this.render_votes()} |  | ||||||
| 						<p> | 						<p> | ||||||
| 							<button class="w3-button w3-dark-grey" @click=${this.react}>React</button> | 							<button class="w3-button w3-theme-d1" @click=${this.react}> | ||||||
|  | 								React | ||||||
|  | 							</button> | ||||||
| 						</p> | 						</p> | ||||||
| 						${this.render_children()} | 						${this.render_children()} | ||||||
| 					</div> | 					</div> | ||||||
| @@ -457,10 +659,13 @@ class TfMessageElement extends LitElement { | |||||||
| 				tfrpc.rpc.get_blob(content.blog).then(function (data) { | 				tfrpc.rpc.get_blob(content.blog).then(function (data) { | ||||||
| 					self.blog_data = data; | 					self.blog_data = data; | ||||||
| 				}); | 				}); | ||||||
| 				let payload = | 				let payload = this.expanded[(this.message.id || '') + ':blog'] | ||||||
| 						this.expanded[(this.message.id || '') + ':blog'] ? | 					? html`<div> | ||||||
| 						html`<div>${this.blog_data ? unsafeHTML(tfutils.markdown(this.blog_data)) : 'Loading...'}</div>` : | 							${this.blog_data | ||||||
| 						undefined; | 								? unsafeHTML(tfutils.markdown(this.blog_data)) | ||||||
|  | 								: 'Loading...'} | ||||||
|  | 						</div>` | ||||||
|  | 					: undefined; | ||||||
| 				let body; | 				let body; | ||||||
| 				switch (this.format) { | 				switch (this.format) { | ||||||
| 					case 'raw': | 					case 'raw': | ||||||
| @@ -473,7 +678,7 @@ class TfMessageElement extends LitElement { | |||||||
| 						body = html` | 						body = html` | ||||||
| 							<div | 							<div | ||||||
| 								style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer" | 								style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer" | ||||||
| 								@click=${x => self.toggle_expanded(':blog')}> | 								@click=${(x) => self.toggle_expanded(':blog')}> | ||||||
| 								<h2>${content.title}</h2> | 								<h2>${content.title}</h2> | ||||||
| 								<div style="display: flex; flex-direction: row"> | 								<div style="display: flex; flex-direction: row"> | ||||||
| 									<img src=/${content.thumbnail}/view></img> | 									<img src=/${content.thumbnail}/view></img> | ||||||
| @@ -484,16 +689,23 @@ class TfMessageElement extends LitElement { | |||||||
| 						`; | 						`; | ||||||
| 						break; | 						break; | ||||||
| 				} | 				} | ||||||
| 				let reply = (this.drafts[this.message?.id] !== undefined) ? html` | 				let reply = | ||||||
|  | 					this.drafts[this.message?.id] !== undefined | ||||||
|  | 						? html` | ||||||
| 								<tf-compose | 								<tf-compose | ||||||
| 									whoami=${this.whoami} | 									whoami=${this.whoami} | ||||||
| 									.users=${this.users} | 									.users=${this.users} | ||||||
| 						root=${this.message.content.root || this.message.id} | 									root=${content.root || this.message.id} | ||||||
| 									branch=${this.message.id} | 									branch=${this.message.id} | ||||||
| 									.drafts=${this.drafts} | 									.drafts=${this.drafts} | ||||||
| 						@tf-discard=${this.discard_reply}></tf-compose> | 									@tf-discard=${this.discard_reply} | ||||||
| 				` : html` | 									author=${this.message.author} | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${this.show_reply}>Reply</button> | 								></tf-compose> | ||||||
|  | 							` | ||||||
|  | 						: html` | ||||||
|  | 								<button class="w3-button w3-theme-d1" @click=${this.show_reply}> | ||||||
|  | 									Reply | ||||||
|  | 								</button> | ||||||
| 							`; | 							`; | ||||||
| 				return html` | 				return html` | ||||||
| 					<style> | 					<style> | ||||||
| @@ -510,11 +722,17 @@ class TfMessageElement extends LitElement { | |||||||
| 							display: block; | 							display: block; | ||||||
| 						} | 						} | ||||||
| 					</style> | 					</style> | ||||||
| 					<div class="w3-card-4" style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px"> | 					<div | ||||||
|  | 						class="w3-card-4 w3-theme-d4 w3-border-theme" | ||||||
|  | 						style="margin-top: 8px; padding: 16px" | ||||||
|  | 					> | ||||||
| 						<div style="display: flex; flex-direction: row"> | 						<div style="display: flex; flex-direction: row"> | ||||||
| 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | 							<tf-user id=${this.message.author} .users=${this.users}></tf-user> | ||||||
| 							<span style="flex: 1"></span> | 							<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 style="padding-right: 8px" | ||||||
|  | 								><a target="_top" href=${'#' + self.message.id}>%</a> | ||||||
|  | 								${new Date(this.message.timestamp).toLocaleString()}</span | ||||||
|  | 							> | ||||||
| 							<span>${raw_button}</span> | 							<span>${raw_button}</span> | ||||||
| 						</div> | 						</div> | ||||||
|  |  | ||||||
| @@ -522,37 +740,52 @@ class TfMessageElement extends LitElement { | |||||||
| 						${this.render_mentions()} | 						${this.render_mentions()} | ||||||
| 						<div> | 						<div> | ||||||
| 							${reply} | 							${reply} | ||||||
| 							<button class="w3-button w3-dark-grey" @click=${this.react}>React</button> | 							<button class="w3-button w3-theme-d1" @click=${this.react}> | ||||||
|  | 								React | ||||||
|  | 							</button> | ||||||
| 						</div> | 						</div> | ||||||
| 						${this.render_votes()} | 						${this.render_votes()} ${this.render_children()} | ||||||
| 						${this.render_children()} |  | ||||||
| 					</div> | 					</div> | ||||||
| 				`; | 				`; | ||||||
| 			} else if (content.type === 'pub') { | 			} else if (content.type === 'pub') { | ||||||
| 				return small_frame(html` | 				return small_frame( | ||||||
| 				<style> | 					html` <style> | ||||||
| 							span { | 							span { | ||||||
| 								overflow-wrap: anywhere; | 								overflow-wrap: anywhere; | ||||||
| 							} | 							} | ||||||
| 						</style> | 						</style> | ||||||
| 						<span> | 						<span> | ||||||
| 							<div> | 							<div> | ||||||
| 						🍻 <tf-user .users=${this.users} id=${content.address.key}></tf-user> | 								🍻 | ||||||
|  | 								<tf-user | ||||||
|  | 									.users=${this.users} | ||||||
|  | 									id=${content.address.key} | ||||||
|  | 								></tf-user> | ||||||
| 							</div> | 							</div> | ||||||
| 							<pre>${content.address.host}:${content.address.port}</pre> | 							<pre>${content.address.host}:${content.address.port}</pre> | ||||||
| 				</span>`); | 						</span>` | ||||||
|  | 				); | ||||||
| 			} else if (content.type === 'channel') { | 			} else if (content.type === 'channel') { | ||||||
| 				return small_frame(html` | 				return small_frame(html` | ||||||
| 					<div> | 					<div> | ||||||
| 						${content.subscribed ? 'subscribed to' : 'unsubscribed from'} <a href=${'#q=' + encodeURIComponent('#' + content.channel)}>#${content.channel}</a> | 						${content.subscribed ? 'subscribed to' : 'unsubscribed from'} | ||||||
|  | 						<a href=${'#q=' + encodeURIComponent('#' + content.channel)} | ||||||
|  | 							>#${content.channel}</a | ||||||
|  | 						> | ||||||
| 					</div> | 					</div> | ||||||
| 				`); | 				`); | ||||||
| 			} else if (typeof(this.message.content) == 'string') { | 			} else if (typeof this.message.content == 'string') { | ||||||
| 				if (this.message?.decrypted) { | 				if (this.message?.decrypted) { | ||||||
| 					if (this.format == 'decrypted') { | 					if (this.format == 'decrypted') { | ||||||
| 						return small_frame(html`<span>🔓</span><pre>${JSON.stringify(this.message.decrypted, null, 2)}</pre>`); | 						return small_frame( | ||||||
|  | 							html`<span>🔓</span> | ||||||
|  | 								<pre>${JSON.stringify(this.message.decrypted, null, 2)}</pre>` | ||||||
|  | 						); | ||||||
| 					} else { | 					} else { | ||||||
| 						return small_frame(html`<span>🔓</span><div>${this.message.decrypted.type}</div>`); | 						return small_frame( | ||||||
|  | 							html`<span>🔓</span> | ||||||
|  | 								<div>${this.message.decrypted.type}</div>` | ||||||
|  | 						); | ||||||
| 					} | 					} | ||||||
| 				} else { | 				} else { | ||||||
| 					return small_frame(html`<span>🔒</span>`); | 					return small_frame(html`<span>🔒</span>`); | ||||||
|   | |||||||
| @@ -61,7 +61,7 @@ class TfNewsElement extends LitElement { | |||||||
| 				message.parent_message = message.content.vote.link; | 				message.parent_message = message.content.vote.link; | ||||||
| 			} else if (message.content.type == 'post') { | 			} else if (message.content.type == 'post') { | ||||||
| 				if (message.content.root) { | 				if (message.content.root) { | ||||||
| 					if (typeof(message.content.root) === 'string') { | 					if (typeof message.content.root === 'string') { | ||||||
| 						let m = ensure_message(message.content.root); | 						let m = ensure_message(message.content.root); | ||||||
| 						if (!m.child_messages) { | 						if (!m.child_messages) { | ||||||
| 							m.child_messages = []; | 							m.child_messages = []; | ||||||
| @@ -89,8 +89,7 @@ class TfNewsElement extends LitElement { | |||||||
| 		for (let message of messages) { | 		for (let message of messages) { | ||||||
| 			try { | 			try { | ||||||
| 				message.content = JSON.parse(message.content); | 				message.content = JSON.parse(message.content); | ||||||
| 			} catch { | 			} catch {} | ||||||
| 			} |  | ||||||
| 			if (!messages_by_id[message.id]) { | 			if (!messages_by_id[message.id]) { | ||||||
| 				messages_by_id[message.id] = message; | 				messages_by_id[message.id] = message; | ||||||
| 				link_message(message); | 				link_message(message); | ||||||
| @@ -100,8 +99,12 @@ class TfNewsElement extends LitElement { | |||||||
| 				message.parent_message = placeholder.parent_message; | 				message.parent_message = placeholder.parent_message; | ||||||
| 				message.child_messages = placeholder.child_messages; | 				message.child_messages = placeholder.child_messages; | ||||||
| 				message.votes = placeholder.votes; | 				message.votes = placeholder.votes; | ||||||
| 				if (placeholder.parent_message && messages_by_id[placeholder.parent_message]) { | 				if ( | ||||||
| 					let children = messages_by_id[placeholder.parent_message].child_messages; | 					placeholder.parent_message && | ||||||
|  | 					messages_by_id[placeholder.parent_message] | ||||||
|  | 				) { | ||||||
|  | 					let children = | ||||||
|  | 						messages_by_id[placeholder.parent_message].child_messages; | ||||||
| 					children.splice(children.indexOf(placeholder), 1); | 					children.splice(children.indexOf(placeholder), 1); | ||||||
| 					children.push(message); | 					children.push(message); | ||||||
| 				} | 				} | ||||||
| @@ -116,7 +119,10 @@ class TfNewsElement extends LitElement { | |||||||
| 		let latest = 0; | 		let latest = 0; | ||||||
| 		for (let message of messages || []) { | 		for (let message of messages || []) { | ||||||
| 			if (message.latest_subtree_timestamp === undefined) { | 			if (message.latest_subtree_timestamp === undefined) { | ||||||
| 				message.latest_subtree_timestamp = Math.max(message.timestamp ?? 0, this.update_latest_subtree_timestamp(message.child_messages)); | 				message.latest_subtree_timestamp = Math.max( | ||||||
|  | 					message.timestamp ?? 0, | ||||||
|  | 					this.update_latest_subtree_timestamp(message.child_messages) | ||||||
|  | 				); | ||||||
| 			} | 			} | ||||||
| 			latest = Math.max(latest, message.latest_subtree_timestamp); | 			latest = Math.max(latest, message.latest_subtree_timestamp); | ||||||
| 		} | 		} | ||||||
| @@ -127,20 +133,22 @@ class TfNewsElement extends LitElement { | |||||||
| 		function recursive_sort(messages, top) { | 		function recursive_sort(messages, top) { | ||||||
| 			if (messages) { | 			if (messages) { | ||||||
| 				if (top) { | 				if (top) { | ||||||
| 					messages.sort((a, b) => b.latest_subtree_timestamp - a.latest_subtree_timestamp); | 					messages.sort( | ||||||
|  | 						(a, b) => b.latest_subtree_timestamp - a.latest_subtree_timestamp | ||||||
|  | 					); | ||||||
| 				} else { | 				} else { | ||||||
| 					messages.sort((a, b) => a.timestamp - b.timestamp); | 					messages.sort((a, b) => a.timestamp - b.timestamp); | ||||||
| 				} | 				} | ||||||
| 				for (let message of messages) { | 				for (let message of messages) { | ||||||
| 					recursive_sort(message.child_messages, false); | 					recursive_sort(message.child_messages, false); | ||||||
| 				} | 				} | ||||||
| 				return messages.map(x => Object.assign({}, x)); | 				return messages.map((x) => Object.assign({}, x)); | ||||||
| 			} else { | 			} else { | ||||||
| 				return {}; | 				return {}; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
|  |  | ||||||
| 		let roots = Object.values(messages_by_id).filter(x => !x.parent_message); | 		let roots = Object.values(messages_by_id).filter((x) => !x.parent_message); | ||||||
| 		this.update_latest_subtree_timestamp(roots); | 		this.update_latest_subtree_timestamp(roots); | ||||||
| 		return recursive_sort(roots, true); | 		return recursive_sort(roots, true); | ||||||
| 	} | 	} | ||||||
| @@ -167,10 +175,22 @@ class TfNewsElement extends LitElement { | |||||||
|  |  | ||||||
| 	load_and_render(messages) { | 	load_and_render(messages) { | ||||||
| 		let messages_by_id = this.process_messages(messages); | 		let messages_by_id = this.process_messages(messages); | ||||||
| 		let final_messages = this.group_following(this.finalize_messages(messages_by_id)); | 		let final_messages = this.group_following( | ||||||
|  | 			this.finalize_messages(messages_by_id) | ||||||
|  | 		); | ||||||
| 		return html` | 		return html` | ||||||
| 			<div style="display: flex; flex-direction: column"> | 			<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>`)} | 				${final_messages.map( | ||||||
|  | 					(x) => | ||||||
|  | 						html`<tf-message | ||||||
|  | 							.message=${x} | ||||||
|  | 							whoami=${this.whoami} | ||||||
|  | 							.users=${this.users} | ||||||
|  | 							.drafts=${this.drafts} | ||||||
|  | 							.expanded=${this.expanded} | ||||||
|  | 							collapsed="true" | ||||||
|  | 						></tf-message>` | ||||||
|  | 				)} | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -36,23 +36,29 @@ class TfProfileElement extends LitElement { | |||||||
| 			this.following = undefined; | 			this.following = undefined; | ||||||
| 			this.blocking = undefined; | 			this.blocking = undefined; | ||||||
|  |  | ||||||
| 			let result = await tfrpc.rpc.query(` | 			let result = await tfrpc.rpc.query( | ||||||
|  | 				` | ||||||
| 				SELECT json_extract(content, '$.following') AS following | 				SELECT json_extract(content, '$.following') AS following | ||||||
| 				FROM messages WHERE author = ? AND | 				FROM messages WHERE author = ? AND | ||||||
| 				json_extract(content, '$.type') = 'contact' AND | 				json_extract(content, '$.type') = 'contact' AND | ||||||
| 				json_extract(content, '$.contact') = ? AND | 				json_extract(content, '$.contact') = ? AND | ||||||
| 				following IS NOT NULL | 				following IS NOT NULL | ||||||
| 				ORDER BY sequence DESC LIMIT 1 | 				ORDER BY sequence DESC LIMIT 1 | ||||||
| 			`, [this.whoami, this.id]); | 			`, | ||||||
|  | 				[this.whoami, this.id] | ||||||
|  | 			); | ||||||
| 			this.following = result?.[0]?.following ?? false; | 			this.following = result?.[0]?.following ?? false; | ||||||
| 			result = await tfrpc.rpc.query(` | 			result = await tfrpc.rpc.query( | ||||||
|  | 				` | ||||||
| 				SELECT json_extract(content, '$.blocking') AS blocking | 				SELECT json_extract(content, '$.blocking') AS blocking | ||||||
| 				FROM messages WHERE author = ? AND | 				FROM messages WHERE author = ? AND | ||||||
| 				json_extract(content, '$.type') = 'contact' AND | 				json_extract(content, '$.type') = 'contact' AND | ||||||
| 				json_extract(content, '$.contact') = ? AND | 				json_extract(content, '$.contact') = ? AND | ||||||
| 				blocking IS NOT NULL | 				blocking IS NOT NULL | ||||||
| 				ORDER BY sequence DESC LIMIT 1 | 				ORDER BY sequence DESC LIMIT 1 | ||||||
| 			`, [this.whoami, this.id]); | 			`, | ||||||
|  | 				[this.whoami, this.id] | ||||||
|  | 			); | ||||||
| 			this.blocking = result?.[0]?.blocking ?? false; | 			this.blocking = result?.[0]?.blocking ?? false; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -60,13 +66,16 @@ class TfProfileElement extends LitElement { | |||||||
| 	async initial_load() { | 	async initial_load() { | ||||||
| 		this.server_follows_me = undefined; | 		this.server_follows_me = undefined; | ||||||
| 		let server_id = await tfrpc.rpc.getServerIdentity(); | 		let server_id = await tfrpc.rpc.getServerIdentity(); | ||||||
| 		let followed = await tfrpc.rpc.query(` | 		let followed = await tfrpc.rpc.query( | ||||||
|  | 			` | ||||||
| 			SELECT json_extract(content, '$.following') AS following | 			SELECT json_extract(content, '$.following') AS following | ||||||
| 			FROM messages | 			FROM messages | ||||||
| 			WHERE author = ? AND | 			WHERE author = ? AND | ||||||
| 			json_extract(content, '$.type') = 'contact' AND | 			json_extract(content, '$.type') = 'contact' AND | ||||||
| 			json_extract(content, '$.contact') = ? ORDER BY sequence DESC LIMIT 1 | 			json_extract(content, '$.contact') = ? ORDER BY sequence DESC LIMIT 1 | ||||||
| 		`, [server_id, this.whoami]); | 		`, | ||||||
|  | 			[server_id, this.whoami] | ||||||
|  | 		); | ||||||
| 		let is_followed = false; | 		let is_followed = false; | ||||||
| 		for (let row of followed) { | 		for (let row of followed) { | ||||||
| 			is_followed = row.following != 0; | 			is_followed = row.following != 0; | ||||||
| @@ -75,11 +84,18 @@ class TfProfileElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	modify(change) { | 	modify(change) { | ||||||
| 		tfrpc.rpc.appendMessage(this.whoami, | 		tfrpc.rpc | ||||||
| 			Object.assign({ | 			.appendMessage( | ||||||
|  | 				this.whoami, | ||||||
|  | 				Object.assign( | ||||||
|  | 					{ | ||||||
| 						type: 'contact', | 						type: 'contact', | ||||||
| 						contact: this.id, | 						contact: this.id, | ||||||
| 			}, change)).catch(function(error) { | 					}, | ||||||
|  | 					change | ||||||
|  | 				) | ||||||
|  | 			) | ||||||
|  | 			.catch(function (error) { | ||||||
| 				alert(error?.message); | 				alert(error?.message); | ||||||
| 			}); | 			}); | ||||||
| 	} | 	} | ||||||
| @@ -122,9 +138,12 @@ class TfProfileElement extends LitElement { | |||||||
| 				message[key] = this.editing[key]; | 				message[key] = this.editing[key]; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		tfrpc.rpc.appendMessage(this.whoami, message).then(function() { | 		tfrpc.rpc | ||||||
|  | 			.appendMessage(this.whoami, message) | ||||||
|  | 			.then(function () { | ||||||
| 				self.editing = null; | 				self.editing = null; | ||||||
| 		}).catch(function(error) { | 			}) | ||||||
|  | 			.catch(function (error) { | ||||||
| 				alert(error?.message); | 				alert(error?.message); | ||||||
| 			}); | 			}); | ||||||
| 	} | 	} | ||||||
| @@ -139,13 +158,17 @@ class TfProfileElement extends LitElement { | |||||||
| 		input.type = 'file'; | 		input.type = 'file'; | ||||||
| 		input.onchange = function (event) { | 		input.onchange = function (event) { | ||||||
| 			let file = event.target.files[0]; | 			let file = event.target.files[0]; | ||||||
| 			file.arrayBuffer().then(function(buffer) { | 			file | ||||||
|  | 				.arrayBuffer() | ||||||
|  | 				.then(function (buffer) { | ||||||
| 					let bin = Array.from(new Uint8Array(buffer)); | 					let bin = Array.from(new Uint8Array(buffer)); | ||||||
| 					return tfrpc.rpc.store_blob(bin); | 					return tfrpc.rpc.store_blob(bin); | ||||||
| 			}).then(function(id) { | 				}) | ||||||
|  | 				.then(function (id) { | ||||||
| 					self.editing = Object.assign({}, self.editing, {image: id}); | 					self.editing = Object.assign({}, self.editing, {image: id}); | ||||||
| 					console.log(self.editing); | 					console.log(self.editing); | ||||||
| 			}).catch(function(e) { | 				}) | ||||||
|  | 				.catch(function (e) { | ||||||
| 					alert(e.message); | 					alert(e.message); | ||||||
| 				}); | 				}); | ||||||
| 		}; | 		}; | ||||||
| @@ -166,15 +189,22 @@ class TfProfileElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		if (this.id == this.whoami && this.editing && this.server_follows_me === undefined) { | 		if ( | ||||||
|  | 			this.id == this.whoami && | ||||||
|  | 			this.editing && | ||||||
|  | 			this.server_follows_me === undefined | ||||||
|  | 		) { | ||||||
| 			this.initial_load(); | 			this.initial_load(); | ||||||
| 		} | 		} | ||||||
| 		this.load(); | 		this.load(); | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let profile = this.users[this.id] || {}; | 		let profile = this.users[this.id] || {}; | ||||||
| 		tfrpc.rpc.query( | 		tfrpc.rpc | ||||||
|  | 			.query( | ||||||
| 				`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`, | 				`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`, | ||||||
| 			[this.id]).then(function(result) { | 				[this.id] | ||||||
|  | 			) | ||||||
|  | 			.then(function (result) { | ||||||
| 				self.size = result[0].size; | 				self.size = result[0].size; | ||||||
| 			}); | 			}); | ||||||
| 		let edit; | 		let edit; | ||||||
| @@ -184,52 +214,75 @@ class TfProfileElement extends LitElement { | |||||||
| 			if (this.editing) { | 			if (this.editing) { | ||||||
| 				let server_follow; | 				let server_follow; | ||||||
| 				if (this.server_follows_me === true) { | 				if (this.server_follows_me === true) { | ||||||
| 					server_follow = html`<button class="w3-button w3-dark-grey" @click=${() => this.server_follow_me(false)}>Server, Stop Following Me</button>`; | 					server_follow = html`<button | ||||||
|  | 						class="w3-button w3-theme-d1" | ||||||
|  | 						@click=${() => this.server_follow_me(false)} | ||||||
|  | 					> | ||||||
|  | 						Server, Stop Following Me | ||||||
|  | 					</button>`; | ||||||
| 				} else if (this.server_follows_me === false) { | 				} else if (this.server_follows_me === false) { | ||||||
| 					server_follow = html`<button class="w3-button w3-dark-grey" @click=${() => this.server_follow_me(true)}>Server, Follow Me</button>`; | 					server_follow = html`<button | ||||||
|  | 						class="w3-button w3-theme-d1" | ||||||
|  | 						@click=${() => this.server_follow_me(true)} | ||||||
|  | 					> | ||||||
|  | 						Server, Follow Me | ||||||
|  | 					</button>`; | ||||||
| 				} | 				} | ||||||
| 				edit = html` | 				edit = html` | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${this.save_edits}>Save Profile</button> | 					<button class="w3-button w3-theme-d1" @click=${this.save_edits}> | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${this.discard_edits}>Discard</button> | 						Save Profile | ||||||
|  | 					</button> | ||||||
|  | 					<button class="w3-button w3-theme-d1" @click=${this.discard_edits}> | ||||||
|  | 						Discard | ||||||
|  | 					</button> | ||||||
| 					${server_follow} | 					${server_follow} | ||||||
| 				`; | 				`; | ||||||
| 			} else { | 			} else { | ||||||
| 				edit = html`<button class="w3-button w3-dark-grey" @click=${this.edit}>Edit Profile</button>`; | 				edit = html`<button class="w3-button w3-theme-d1" @click=${this.edit}> | ||||||
|  | 					Edit Profile | ||||||
|  | 				</button>`; | ||||||
| 			} | 			} | ||||||
| 		} | 		} | ||||||
| 		if (this.id !== this.whoami && | 		if (this.id !== this.whoami && this.following !== undefined) { | ||||||
| 			this.following !== undefined) { | 			follow = this.following | ||||||
| 			follow = | 				? html`<button class="w3-button w3-theme-d1" @click=${this.unfollow}> | ||||||
| 				this.following ? | 						Unfollow | ||||||
| 				html`<button class="w3-button w3-dark-grey" @click=${this.unfollow}>Unfollow</button>` : | 					</button>` | ||||||
| 				html`<button class="w3-button w3-dark-grey" @click=${this.follow}>Follow</button>`; | 				: html`<button class="w3-button w3-theme-d1" @click=${this.follow}> | ||||||
|  | 						Follow | ||||||
|  | 					</button>`; | ||||||
| 		} | 		} | ||||||
| 		if (this.id !== this.whoami && | 		if (this.id !== this.whoami && this.blocking !== undefined) { | ||||||
| 			this.blocking !== undefined) { | 			block = this.blocking | ||||||
| 			block = | 				? html`<button class="w3-button w3-theme-d1" @click=${this.unblock}> | ||||||
| 				this.blocking ? | 						Unblock | ||||||
| 				html`<button class="w3-button w3-dark-grey" @click=${this.unblock}>Unblock</button>` : | 					</button>` | ||||||
| 				html`<button class="w3-button w3-dark-grey" @click=${this.block}>Block</button>`; | 				: html`<button class="w3-button w3-theme-d1" @click=${this.block}> | ||||||
|  | 						Block | ||||||
|  | 					</button>`; | ||||||
| 		} | 		} | ||||||
| 		let edit_profile = this.editing ? html` | 		let edit_profile = this.editing | ||||||
|  | 			? html` | ||||||
| 			<div style="flex: 1 0 50%; display: flex; flex-direction: column; gap: 8px"> | 			<div style="flex: 1 0 50%; display: flex; flex-direction: column; gap: 8px"> | ||||||
| 				<div class="w3-container"> | 				<div class="w3-container"> | ||||||
| 					<div> | 					<div> | ||||||
| 						<label for="name">Name:</label> | 						<label for="name">Name:</label> | ||||||
| 						<input class="w3-input w3-dark-grey" type="text" id="name" value=${this.editing.name} @input=${event => this.editing = Object.assign({}, this.editing, {name: event.srcElement.value})}></input> | 						<input class="w3-input w3-theme-d1" type="text" id="name" value=${this.editing.name} @input=${(event) => (this.editing = Object.assign({}, this.editing, {name: event.srcElement.value}))}></input> | ||||||
| 					</div> | 					</div> | ||||||
| 					<div><label for="description">Description:</label></div> | 					<div><label for="description">Description:</label></div> | ||||||
| 					<textarea class="w3-input w3-dark-grey" style="resize: vertical" rows="8" id="description" @input=${event => this.editing = Object.assign({}, this.editing, {description: event.srcElement.value})}>${this.editing.description}</textarea> | 					<textarea class="w3-input w3-theme-d1" style="resize: vertical" rows="8" id="description" @input=${(event) => (this.editing = Object.assign({}, this.editing, {description: event.srcElement.value}))}>${this.editing.description}</textarea> | ||||||
| 					<div> | 					<div> | ||||||
| 						<label for="public_web_hosting">Public Web Hosting:</label> | 						<label for="public_web_hosting">Public Web Hosting:</label> | ||||||
| 						<input class="w3-check w3-dark-grey" type="checkbox" id="public_web_hosting" ?checked=${this.editing.publicWebHosting} @input=${event => self.editing = Object.assign({}, self.editing, {publicWebHosting: event.srcElement.checked})}></input> | 						<input class="w3-check w3-theme-d1" type="checkbox" id="public_web_hosting" ?checked=${this.editing.publicWebHosting} @input=${(event) => (self.editing = Object.assign({}, self.editing, {publicWebHosting: event.srcElement.checked}))}></input> | ||||||
| 					</div> | 					</div> | ||||||
| 					<div> | 					<div> | ||||||
| 						<button class="w3-button w3-dark-grey" @click=${this.attach_image}>Attach Image</button> | 						<button class="w3-button w3-theme-d1" @click=${this.attach_image}>Attach Image</button> | ||||||
| 					</div> | 					</div> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div>` : null; | 			</div>` | ||||||
| 		let image = typeof(profile.image) == 'string' ? profile.image : profile.image?.link; | 			: null; | ||||||
|  | 		let image = | ||||||
|  | 			typeof profile.image == 'string' ? profile.image : profile.image?.link; | ||||||
| 		image = this.editing?.image ?? image; | 		image = this.editing?.image ?? image; | ||||||
| 		let description = this.editing?.description ?? profile.description; | 		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"> | 		return html`<div style="border: 2px solid black; background-color: rgba(255, 255, 255, 0.2); padding: 16px"> | ||||||
|   | |||||||
							
								
								
									
										68
									
								
								apps/ssb/tf-reactions-modal.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								apps/ssb/tf-reactions-modal.js
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,68 @@ | |||||||
|  | import {LitElement, html, unsafeHTML} from './lit-all.min.js'; | ||||||
|  | import {styles} from './tf-styles.js'; | ||||||
|  |  | ||||||
|  | class TfReactionsModalElement extends LitElement { | ||||||
|  | 	static get properties() { | ||||||
|  | 		return { | ||||||
|  | 			users: {type: Object}, | ||||||
|  | 			votes: {type: Array}, | ||||||
|  | 		}; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	static styles = styles; | ||||||
|  |  | ||||||
|  | 	constructor() { | ||||||
|  | 		super(); | ||||||
|  | 		this.votes = []; | ||||||
|  | 		this.users = {}; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	clear() { | ||||||
|  | 		this.votes = []; | ||||||
|  | 	} | ||||||
|  |  | ||||||
|  | 	render() { | ||||||
|  | 		let self = this; | ||||||
|  | 		return this.votes?.length | ||||||
|  | 			? html` <div | ||||||
|  | 					class="w3-modal w3-animate-opacity" | ||||||
|  | 					style="display: block; box-sizing: border-box" | ||||||
|  | 				> | ||||||
|  | 					<div class="w3-modal-content w3-card-4 w3-theme-d1"> | ||||||
|  | 						<div class="w3-container w3-padding"> | ||||||
|  | 							<header class="w3-container"> | ||||||
|  | 								<h2>Reactions</h2> | ||||||
|  | 								<span class="w3-button w3-display-topright" @click=${this.clear} | ||||||
|  | 									>×</span | ||||||
|  | 								> | ||||||
|  | 							</header> | ||||||
|  | 							<ul class="w3-theme-dark w3-container w3-ul"> | ||||||
|  | 								${this.votes.map( | ||||||
|  | 									(x) => html` | ||||||
|  | 										<li class="w3-bar"> | ||||||
|  | 											<span class="w3-bar-item" | ||||||
|  | 												>${x?.content?.vote?.expression}</span | ||||||
|  | 											> | ||||||
|  | 											<tf-user | ||||||
|  | 												class="w3-bar-item" | ||||||
|  | 												id=${x.author} | ||||||
|  | 												.users=${this.users} | ||||||
|  | 											></tf-user> | ||||||
|  | 											<span class="w3-bar-item w3-right" | ||||||
|  | 												>${new Date(x?.timestamp).toLocaleString()}</span | ||||||
|  | 											> | ||||||
|  | 										</li> | ||||||
|  | 									` | ||||||
|  | 								)} | ||||||
|  | 							</ul> | ||||||
|  | 							<footer class="w3-container w3-padding"> | ||||||
|  | 								<button class="w3-button" @click=${this.clear}>Close</button> | ||||||
|  | 							</footer> | ||||||
|  | 						</div> | ||||||
|  | 					</div> | ||||||
|  | 				</div>` | ||||||
|  | 			: undefined; | ||||||
|  | 	} | ||||||
|  | } | ||||||
|  |  | ||||||
|  | customElements.define('tf-reactions-modal', TfReactionsModalElement); | ||||||
| @@ -1,18 +1,6 @@ | |||||||
| import {css} from './lit-all.min.js'; | import {css} from './lit-all.min.js'; | ||||||
|  |  | ||||||
| const tf = css` | const tf = css` | ||||||
| a:link { |  | ||||||
| 	color: #bbf; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| a:visited { |  | ||||||
| 	color: #ddd; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| a:hover { |  | ||||||
| 	color: #ddf; |  | ||||||
| } |  | ||||||
|  |  | ||||||
| 	img { | 	img { | ||||||
| 		max-width: min(640px, 100%); | 		max-width: min(640px, 100%); | ||||||
| 		max-height: min(480px, auto); | 		max-height: min(480px, auto); | ||||||
| @@ -46,22 +34,19 @@ div.img_caption::after { | |||||||
| 		content: ' ±'; | 		content: ' ±'; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| code { | 	pre code { | ||||||
| 	background-color: #444; | 		display: block; | ||||||
| 	padding-left: 3px; | 		padding: 8px; | ||||||
| 	padding-right: 3px; |  | ||||||
| 	border: 1px dotted #fff; |  | ||||||
| 	border-radius: 4px; |  | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	blockquote { | 	blockquote { | ||||||
| 	background-color: #607d8b; |  | ||||||
| 		border-left: 4px solid #fff; | 		border-left: 4px solid #fff; | ||||||
| 		padding: 8px; | 		padding: 8px; | ||||||
| 		padding-left: 12px; | 		padding-left: 12px; | ||||||
| 	} | 	} | ||||||
| `; | `; | ||||||
|  |  | ||||||
|  | // prettier-ignore | ||||||
| const w3 = css` | const w3 = css` | ||||||
| /* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ | /* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ | ||||||
| html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} | html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} | ||||||
| @@ -300,4 +285,30 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0} | |||||||
| .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} | .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} | ||||||
| `; | `; | ||||||
|  |  | ||||||
| export let styles = [tf, w3]; | // prettier-ignore | ||||||
|  | const w3_2016_riverside = css` | ||||||
|  | .w3-theme-l5 {color:#000 !important; background-color:#f4f6f9 !important} | ||||||
|  | .w3-theme-l4 {color:#000 !important; background-color:#d9e1ec !important} | ||||||
|  | .w3-theme-l3 {color:#000 !important; background-color:#b4c3d8 !important} | ||||||
|  | .w3-theme-l2 {color:#fff !important; background-color:#8ea6c5 !important} | ||||||
|  | .w3-theme-l1 {color:#fff !important; background-color:#6888b1 !important} | ||||||
|  | .w3-theme-d1 {color:#fff !important; background-color:#456185 !important} | ||||||
|  | .w3-theme-d2 {color:#fff !important; background-color:#3d5676 !important} | ||||||
|  | .w3-theme-d3 {color:#fff !important; background-color:#354b68 !important} | ||||||
|  | .w3-theme-d4 {color:#fff !important; background-color:#2e4059 !important} | ||||||
|  | .w3-theme-d5 {color:#fff !important; background-color:#26364a !important} | ||||||
|  |  | ||||||
|  | .w3-theme-light {color:#000 !important; background-color:#f4f6f9 !important} | ||||||
|  | .w3-theme-dark {color:#fff !important; background-color:#26364a !important} | ||||||
|  | .w3-theme-action {color:#fff !important; background-color:#26364a !important} | ||||||
|  |  | ||||||
|  | .w3-theme {color:#fff !important; background-color:#4c6a92 !important} | ||||||
|  | .w3-text-theme {color:#4c6a92 !important} | ||||||
|  | .w3-border-theme {border-color:#4c6a92 !important} | ||||||
|  |  | ||||||
|  | .w3-hover-theme:hover {color:#fff !important; background-color:#4c6a92 !important} | ||||||
|  | .w3-hover-text-theme:hover {color:#4c6a92 !important} | ||||||
|  | .w3-hover-border-theme:hover {border-color:#4c6a92 !important} | ||||||
|  | `; | ||||||
|  |  | ||||||
|  | export let styles = [tf, w3, w3_2016_riverside]; | ||||||
|   | |||||||
| @@ -10,6 +10,7 @@ class TfTabConnectionsElement extends LitElement { | |||||||
| 			connections: {type: Array}, | 			connections: {type: Array}, | ||||||
| 			stored_connections: {type: Array}, | 			stored_connections: {type: Array}, | ||||||
| 			users: {type: Object}, | 			users: {type: Object}, | ||||||
|  | 			server_identity: {type: String}, | ||||||
| 		}; | 		}; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -29,13 +30,18 @@ class TfTabConnectionsElement extends LitElement { | |||||||
| 		tfrpc.rpc.getStoredConnections().then(function (connections) { | 		tfrpc.rpc.getStoredConnections().then(function (connections) { | ||||||
| 			self.stored_connections = connections || []; | 			self.stored_connections = connections || []; | ||||||
| 		}); | 		}); | ||||||
|  | 		tfrpc.rpc.getServerIdentity().then(function (identity) { | ||||||
|  | 			self.server_identity = identity; | ||||||
|  | 		}); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render_connection_summary(connection) { | 	render_connection_summary(connection) { | ||||||
| 		if (connection.address && connection.port) { | 		if (connection.address && connection.port) { | ||||||
| 			return html`(<small>${connection.address}:${connection.port}</small>)`; | 			return html`<div> | ||||||
|  | 				<small>${connection.address}:${connection.port}</small> | ||||||
|  | 			</div>`; | ||||||
| 		} else if (connection.tunnel) { | 		} else if (connection.tunnel) { | ||||||
| 			return html`(room peer)`; | 			return html`<div>room peer</div>`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return JSON.stringify(connection); | 			return JSON.stringify(connection); | ||||||
| 		} | 		} | ||||||
| @@ -43,10 +49,12 @@ class TfTabConnectionsElement extends LitElement { | |||||||
|  |  | ||||||
| 	render_room_peers(connection) { | 	render_room_peers(connection) { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let peers = this.broadcasts.filter(x => x.tunnel?.id == connection); | 		let peers = this.broadcasts.filter((x) => x.tunnel?.id == connection); | ||||||
| 		if (peers.length) { | 		if (peers.length) { | ||||||
| 			let connections = this.connections.map(x => x.id); | 			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)}`)}`; | 			return html`${peers | ||||||
|  | 				.filter((x) => connections.indexOf(x.pubkey) == -1) | ||||||
|  | 				.map((x) => html`${self.render_room_peer(x)}`)}`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -58,7 +66,12 @@ class TfTabConnectionsElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` | ||||||
| 			<li> | 			<li> | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)}>Connect</button> | 				<button | ||||||
|  | 					class="w3-button w3-theme-d1" | ||||||
|  | 					@click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)} | ||||||
|  | 				> | ||||||
|  | 					Connect | ||||||
|  | 				</button> | ||||||
| 				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> 📡 | 				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> 📡 | ||||||
| 			</li> | 			</li> | ||||||
| 		`; | 		`; | ||||||
| @@ -66,10 +79,17 @@ class TfTabConnectionsElement extends LitElement { | |||||||
|  |  | ||||||
| 	render_broadcast(connection) { | 	render_broadcast(connection) { | ||||||
| 		return html` | 		return html` | ||||||
| 			<li> | 			<li class="w3-bar" style="overflow: hidden; overflow-wrap: nowrap"> | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(connection)}>Connect</button> | 				<button | ||||||
|  | 					class="w3-bar-item w3-button w3-theme-d1" | ||||||
|  | 					@click=${() => tfrpc.rpc.connect(connection)} | ||||||
|  | 				> | ||||||
|  | 					Connect | ||||||
|  | 				</button> | ||||||
|  | 				<div class="w3-bar-item"> | ||||||
| 					<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> | 					<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> | ||||||
| 					${this.render_connection_summary(connection)} | 					${this.render_connection_summary(connection)} | ||||||
|  | 				</div> | ||||||
| 			</li> | 			</li> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| @@ -81,11 +101,23 @@ class TfTabConnectionsElement extends LitElement { | |||||||
|  |  | ||||||
| 	render_connection(connection) { | 	render_connection(connection) { | ||||||
| 		return html` | 		return html` | ||||||
| 			<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.closeConnection(connection.id)}>Close</button> | 			<button | ||||||
|  | 				class="w3-button w3-theme-d1" | ||||||
|  | 				@click=${() => tfrpc.rpc.closeConnection(connection.id)} | ||||||
|  | 			> | ||||||
|  | 				Close | ||||||
|  | 			</button> | ||||||
| 			<tf-user id=${connection.id} .users=${this.users}></tf-user> | 			<tf-user id=${connection.id} .users=${this.users}></tf-user> | ||||||
| 			${connection.tunnel !== undefined ? '🚇' : html`(${connection.host}:${connection.port})`} | 			${connection.tunnel !== undefined | ||||||
|  | 				? '🚇' | ||||||
|  | 				: html`(${connection.host}:${connection.port})`} | ||||||
|  | 			<div>${connection.requests.map(x => html` | ||||||
|  | 				<span class="w3-tag w3-small">${x.request_number > 0 ? '🟩' : '🟥'} ${x.name}</span> | ||||||
|  | 			`)}</div> | ||||||
| 			<ul> | 			<ul> | ||||||
| 				${this.connections.filter(x => x.tunnel === this.connections.indexOf(connection)).map(x => html`<li>${this.render_connection(x)}</li>`)} | 				${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)} | 				${this.render_room_peers(connection.id)} | ||||||
| 			</ul> | 			</ul> | ||||||
| 		`; | 		`; | ||||||
| @@ -94,33 +126,66 @@ class TfTabConnectionsElement extends LitElement { | |||||||
| 	render() { | 	render() { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` | ||||||
| 			<div class="w3-container"> | 			<div class="w3-container" style="box-sizing: border-box"> | ||||||
| 				<h2>New Connection</h2> | 				<h2>New Connection</h2> | ||||||
| 				<textarea class="w3-input w3-dark-grey" id="code"></textarea> | 				<textarea class="w3-input w3-theme-d1" id="code"></textarea> | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(self.renderRoot.getElementById('code').value)}>Connect</button> | 				<button | ||||||
|  | 					class="w3-button w3-theme-d1" | ||||||
|  | 					@click=${() => | ||||||
|  | 						tfrpc.rpc.connect(self.renderRoot.getElementById('code').value)} | ||||||
|  | 				> | ||||||
|  | 					Connect | ||||||
|  | 				</button> | ||||||
| 				<h2>Broadcasts</h2> | 				<h2>Broadcasts</h2> | ||||||
| 				<ul> | 				<ul class="w3-ul w3-border"> | ||||||
| 					${this.broadcasts.filter(x => x.address).map(x => self.render_broadcast(x))} | 					${this.broadcasts | ||||||
|  | 						.filter((x) => x.address) | ||||||
|  | 						.map((x) => self.render_broadcast(x))} | ||||||
| 				</ul> | 				</ul> | ||||||
| 				<h2>Connections</h2> | 				<h2>Connections</h2> | ||||||
| 				<ul> | 				<ul class="w3-ul w3-border"> | ||||||
| 					${this.connections.filter(x => x.tunnel === undefined).map(x => html` | 					${this.connections | ||||||
| 						<li>${this.render_connection(x)}</li> | 						.filter((x) => x.tunnel === undefined) | ||||||
| 					`)} | 						.map( | ||||||
|  | 							(x) => html` | ||||||
|  | 								<li class="w3-bar">${this.render_connection(x)}</li> | ||||||
|  | 							` | ||||||
|  | 						)} | ||||||
| 				</ul> | 				</ul> | ||||||
| 				<h2>Stored Connections (WIP)</h2> | 				<h2>Stored Connections</h2> | ||||||
| 				<ul> | 				<ul class="w3-ul w3-border"> | ||||||
| 					${this.stored_connections.map(x => html` | 					${this.stored_connections.map( | ||||||
| 						<li> | 						(x) => html` | ||||||
| 							<button class="w3-button w3-dark-grey" @click=${() => self.forget_stored_connection(x)}>Forget</button> | 							<li class="w3-bar"> | ||||||
| 							<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(x)}>Connect</button> | 								<button | ||||||
| 							${x.address}:${x.port} <tf-user id=${x.pubkey} .users=${self.users}></tf-user> | 									class="w3-bar-item w3-button w3-theme-d1" | ||||||
|  | 									@click=${() => self.forget_stored_connection(x)} | ||||||
|  | 								> | ||||||
|  | 									Forget | ||||||
|  | 								</button> | ||||||
|  | 								<button | ||||||
|  | 									class="w3-bar-item w3-button w3-theme-d1" | ||||||
|  | 									@click=${() => tfrpc.rpc.connect(x)} | ||||||
|  | 								> | ||||||
|  | 									Connect | ||||||
|  | 								</button> | ||||||
|  | 								<div class="w3-bar-item"> | ||||||
|  | 									<tf-user id=${x.pubkey} .users=${self.users}></tf-user> | ||||||
|  | 									<div><small>${x.address}:${x.port}</small></div> | ||||||
|  | 								</div> | ||||||
| 							</li> | 							</li> | ||||||
| 					`)} | 						` | ||||||
|  | 					)} | ||||||
| 				</ul> | 				</ul> | ||||||
| 				<h2>Local Accounts</h2> | 				<h2>Local Accounts</h2> | ||||||
| 				<ul> | 				<ul class="w3-ul w3-border"> | ||||||
| 					${this.identities.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)} | 					${this.identities.map( | ||||||
|  | 						(x) => | ||||||
|  | 							html`<li class="w3-bar"> | ||||||
|  | 								<tf-user id=${x} .users=${this.users}></tf-user> | ||||||
|  | 								${x == this.server_identity ? html`- 🖥local server` : undefined} | ||||||
|  | 							</li>` | ||||||
|  | 					)} | ||||||
| 				</ul> | 				</ul> | ||||||
| 			</div> | 			</div> | ||||||
| 		`; | 		`; | ||||||
|   | |||||||
| @@ -27,15 +27,21 @@ class TfTabMentionsElement extends LitElement { | |||||||
|  |  | ||||||
| 	async load() { | 	async load() { | ||||||
| 		console.log('Loading...', this.whoami); | 		console.log('Loading...', this.whoami); | ||||||
| 		let results = await tfrpc.rpc.query(` | 		let results = await tfrpc.rpc.query( | ||||||
| 				SELECT messages.* | 			` | ||||||
|  | 				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 				FROM messages_fts(?) | 				FROM messages_fts(?) | ||||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | 				JOIN json_each(?) AS following ON messages.author = following.value | ||||||
| 				WHERE messages.author != ? | 				WHERE messages.author != ? | ||||||
| 				ORDER BY timestamp DESC limit 20 | 				ORDER BY timestamp DESC limit 20 | ||||||
| 			`, | 			`, | ||||||
| 			['"' + this.whoami.replace('"', '""') + '"', JSON.stringify(this.following), this.whoami]); | 			[ | ||||||
|  | 				'"' + this.whoami.replace('"', '""') + '"', | ||||||
|  | 				JSON.stringify(this.following), | ||||||
|  | 				this.whoami, | ||||||
|  | 			] | ||||||
|  | 		); | ||||||
| 		console.log('Done.'); | 		console.log('Done.'); | ||||||
| 		this.messages = results; | 		this.messages = results; | ||||||
| 	} | 	} | ||||||
| @@ -58,7 +64,14 @@ class TfTabMentionsElement extends LitElement { | |||||||
| 			this.load(); | 			this.load(); | ||||||
| 		} | 		} | ||||||
| 		return html` | 		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> | 			<tf-news | ||||||
|  | 				id="news" | ||||||
|  | 				whoami=${this.whoami} | ||||||
|  | 				.messages=${this.messages} | ||||||
|  | 				.users=${this.users} | ||||||
|  | 				.expanded=${this.expanded} | ||||||
|  | 				@tf-expand=${this.on_expand} | ||||||
|  | 			></tf-news> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -33,54 +33,53 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 		if (this.hash.startsWith('#@')) { | 		if (this.hash.startsWith('#@')) { | ||||||
| 			let r = await tfrpc.rpc.query( | 			let r = await tfrpc.rpc.query( | ||||||
| 				` | 				` | ||||||
| 					WITH mine AS (SELECT messages.* | 					WITH mine AS (SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||||
| 						FROM messages | 						FROM messages | ||||||
| 						WHERE messages.author = ? | 						WHERE messages.author = ? | ||||||
| 						ORDER BY sequence DESC | 						ORDER BY sequence DESC | ||||||
| 						LIMIT 20) | 						LIMIT 20) | ||||||
| 					SELECT messages.* | 					SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 						FROM mine | 						FROM mine | ||||||
| 						JOIN messages_refs ON mine.id = messages_refs.ref | 						JOIN messages_refs ON mine.id = messages_refs.ref | ||||||
| 						JOIN messages ON messages_refs.message = messages.id | 						JOIN messages ON messages_refs.message = messages.id | ||||||
| 					UNION | 					UNION | ||||||
| 					SELECT * FROM mine | 					SELECT * FROM mine | ||||||
| 				`, | 				`, | ||||||
| 				[ | 				[this.hash.substring(1)] | ||||||
| 					this.hash.substring(1), | 			); | ||||||
| 				]); |  | ||||||
| 			return r; | 			return r; | ||||||
| 		} else if (this.hash.startsWith('#%')) { | 		} else if (this.hash.startsWith('#%')) { | ||||||
| 			return await tfrpc.rpc.query( | 			return await tfrpc.rpc.query( | ||||||
| 				` | 				` | ||||||
| 					SELECT messages.* | 					SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||||
| 					FROM messages | 					FROM messages | ||||||
| 					WHERE id = ?1 | 					WHERE id = ?1 | ||||||
| 					UNION | 					UNION | ||||||
| 					SELECT messages.* | 					SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature | ||||||
| 					FROM messages JOIN messages_refs | 					FROM messages JOIN messages_refs | ||||||
| 					ON messages.id = messages_refs.message | 					ON messages.id = messages_refs.message | ||||||
| 					WHERE messages_refs.ref = ?1 | 					WHERE messages_refs.ref = ?1 | ||||||
| 				`, | 				`, | ||||||
| 				[ | 				[this.hash.substring(1)] | ||||||
| 					this.hash.substring(1), | 			); | ||||||
| 				]); |  | ||||||
| 		} else { | 		} else { | ||||||
| 			let promises = []; | 			let promises = []; | ||||||
| 			const k_following_limit = 256; | 			const k_following_limit = 256; | ||||||
| 			for (let i = 0; i < this.following.length; i += k_following_limit) { | 			for (let i = 0; i < this.following.length; i += k_following_limit) { | ||||||
| 				promises.push(tfrpc.rpc.query( | 				promises.push( | ||||||
|  | 					tfrpc.rpc.query( | ||||||
| 						` | 						` | ||||||
| 						WITH news AS (SELECT messages.* | 						WITH news AS (SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 						FROM messages | 						FROM messages | ||||||
| 						JOIN json_each(?) AS following ON messages.author = following.value | 						JOIN json_each(?) AS following ON messages.author = following.value | ||||||
| 						WHERE messages.timestamp > ? AND messages.timestamp < ? | 						WHERE messages.timestamp > ? AND messages.timestamp < ? | ||||||
| 						ORDER BY messages.timestamp DESC) | 						ORDER BY messages.timestamp DESC) | ||||||
| 						SELECT messages.* | 						SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 							FROM news | 							FROM news | ||||||
| 							JOIN messages_refs ON news.id = messages_refs.ref | 							JOIN messages_refs ON news.id = messages_refs.ref | ||||||
| 							JOIN messages ON messages_refs.message = messages.id | 							JOIN messages ON messages_refs.message = messages.id | ||||||
| 						UNION | 						UNION | ||||||
| 						SELECT messages.* | 						SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 							FROM news | 							FROM news | ||||||
| 							JOIN messages_refs ON news.id = messages_refs.message | 							JOIN messages_refs ON news.id = messages_refs.message | ||||||
| 							JOIN messages ON messages_refs.ref = messages.id | 							JOIN messages ON messages_refs.ref = messages.id | ||||||
| @@ -95,7 +94,9 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 							 ** messages with far-future timestamps from staying at the top forever. | 							 ** messages with far-future timestamps from staying at the top forever. | ||||||
| 							 */ | 							 */ | ||||||
| 							new Date().valueOf() + 24 * 60 * 60 * 1000, | 							new Date().valueOf() + 24 * 60 * 60 * 1000, | ||||||
| 					])); | 						] | ||||||
|  | 					) | ||||||
|  | 				); | ||||||
| 			} | 			} | ||||||
| 			return [].concat(...(await Promise.all(promises))); | 			return [].concat(...(await Promise.all(promises))); | ||||||
| 		} | 		} | ||||||
| @@ -106,29 +107,26 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 		this.start_time = last_start_time - 24 * 60 * 60 * 1000; | 		this.start_time = last_start_time - 24 * 60 * 60 * 1000; | ||||||
| 		let more = await tfrpc.rpc.query( | 		let more = await tfrpc.rpc.query( | ||||||
| 			` | 			` | ||||||
| 				WITH news AS (SELECT messages.* | 				WITH news AS (SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 				FROM messages | 				FROM messages | ||||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | 				JOIN json_each(?) AS following ON messages.author = following.value | ||||||
| 				WHERE messages.timestamp > ? | 				WHERE messages.timestamp > ? | ||||||
| 				AND messages.timestamp <= ? | 				AND messages.timestamp <= ? | ||||||
| 				ORDER BY messages.timestamp DESC) | 				ORDER BY messages.timestamp DESC) | ||||||
| 				SELECT messages.* | 				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 					FROM news | 					FROM news | ||||||
| 					JOIN messages_refs ON news.id = messages_refs.ref | 					JOIN messages_refs ON news.id = messages_refs.ref | ||||||
| 					JOIN messages ON messages_refs.message = messages.id | 					JOIN messages ON messages_refs.message = messages.id | ||||||
| 				UNION | 				UNION | ||||||
| 				SELECT messages.* | 				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 					FROM news | 					FROM news | ||||||
| 					JOIN messages_refs ON news.id = messages_refs.message | 					JOIN messages_refs ON news.id = messages_refs.message | ||||||
| 					JOIN messages ON messages_refs.ref = messages.id | 					JOIN messages ON messages_refs.ref = messages.id | ||||||
| 				UNION | 				UNION | ||||||
| 				SELECT news.* FROM news | 				SELECT news.* FROM news | ||||||
| 			`, | 			`, | ||||||
| 			[ | 			[JSON.stringify(this.following), this.start_time, last_start_time] | ||||||
| 				JSON.stringify(this.following), | 		); | ||||||
| 				this.start_time, |  | ||||||
| 				last_start_time, |  | ||||||
| 			]); |  | ||||||
| 		this.messages = await this.decrypt([...more, ...this.messages]); | 		this.messages = await this.decrypt([...more, ...this.messages]); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -139,14 +137,12 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 			let content; | 			let content; | ||||||
| 			try { | 			try { | ||||||
| 				content = JSON.parse(message?.content); | 				content = JSON.parse(message?.content); | ||||||
| 			} catch { | 			} catch {} | ||||||
| 			} | 			if (typeof content === 'string') { | ||||||
| 			if (typeof(content) === 'string') { |  | ||||||
| 				let decrypted; | 				let decrypted; | ||||||
| 				try { | 				try { | ||||||
| 					decrypted = await tfrpc.rpc.try_decrypt(this.whoami, content); | 					decrypted = await tfrpc.rpc.try_decrypt(this.whoami, content); | ||||||
| 				} catch { | 				} catch {} | ||||||
| 				} |  | ||||||
| 				if (decrypted) { | 				if (decrypted) { | ||||||
| 					try { | 					try { | ||||||
| 						message.decrypted = JSON.parse(decrypted); | 						message.decrypted = JSON.parse(decrypted); | ||||||
| @@ -165,18 +161,25 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		if (!this.messages || | 		if ( | ||||||
|  | 			!this.messages || | ||||||
| 			this._messages_hash !== this.hash || | 			this._messages_hash !== this.hash || | ||||||
| 			this._messages_following !== this.following) { | 			this._messages_following !== this.following | ||||||
| 			console.log(`loading messages for ${this.whoami} (following ${this.following.length})`); | 		) { | ||||||
|  | 			console.log( | ||||||
|  | 				`loading messages for ${this.whoami} (following ${this.following.length})` | ||||||
|  | 			); | ||||||
| 			let self = this; | 			let self = this; | ||||||
| 			this.messages = []; | 			this.messages = []; | ||||||
| 			this._messages_hash = this.hash; | 			this._messages_hash = this.hash; | ||||||
| 			this._messages_following = this.following; | 			this._messages_following = this.following; | ||||||
| 			this.fetch_messages().then(this.decrypt.bind(this)).then(function(messages) { | 			this.fetch_messages() | ||||||
|  | 				.then(this.decrypt.bind(this)) | ||||||
|  | 				.then(function (messages) { | ||||||
| 					self.messages = messages; | 					self.messages = messages; | ||||||
| 					console.log(`loading mesages done for ${self.whoami}`); | 					console.log(`loading mesages done for ${self.whoami}`); | ||||||
| 			}).catch(function(error) { | 				}) | ||||||
|  | 				.catch(function (error) { | ||||||
| 					alert(JSON.stringify(error, null, 2)); | 					alert(JSON.stringify(error, null, 2)); | ||||||
| 				}); | 				}); | ||||||
| 		} | 		} | ||||||
| @@ -184,12 +187,22 @@ class TfTabNewsFeedElement extends LitElement { | |||||||
| 		if (!this.hash.startsWith('#@') && !this.hash.startsWith('#%')) { | 		if (!this.hash.startsWith('#@') && !this.hash.startsWith('#%')) { | ||||||
| 			more = html` | 			more = html` | ||||||
| 				<p> | 				<p> | ||||||
| 					<button class="w3-button w3-dark-grey" @click=${this.load_more}>Load More</button> | 					<button class="w3-button w3-theme-d1" @click=${this.load_more}> | ||||||
|  | 						Load More | ||||||
|  | 					</button> | ||||||
| 				</p> | 				</p> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| 		return html` | 		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> | 			<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} | 			${more} | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -48,7 +48,9 @@ class TfTabNewsElement extends LitElement { | |||||||
| 		let news = this.shadowRoot?.getElementById('news'); | 		let news = this.shadowRoot?.getElementById('news'); | ||||||
| 		if (news) { | 		if (news) { | ||||||
| 			console.log('injecting messages', news.messages); | 			console.log('injecting messages', news.messages); | ||||||
| 			news.add_messages(Object.values(Object.fromEntries(this.unread.map(x => [x.id, x])))); | 			news.add_messages( | ||||||
|  | 				Object.values(Object.fromEntries(this.unread.map((x) => [x.id, x]))) | ||||||
|  | 			); | ||||||
| 			this.dispatchEvent(new CustomEvent('refresh')); | 			this.dispatchEvent(new CustomEvent('refresh')); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -62,11 +64,16 @@ class TfTabNewsElement extends LitElement { | |||||||
| 			let type = 'private'; | 			let type = 'private'; | ||||||
| 			try { | 			try { | ||||||
| 				type = JSON.parse(message.content).type || type; | 				type = JSON.parse(message.content).type || type; | ||||||
| 			} catch { | 			} catch {} | ||||||
| 			} |  | ||||||
| 			counts[type] = (counts[type] || 0) + 1; | 			counts[type] = (counts[type] || 0) + 1; | ||||||
| 		} | 		} | ||||||
| 		return '↻ Show New: ' + Object.keys(counts).sort().map(x => (counts[x].toString() + ' ' + x + 's')).join(', '); | 		return ( | ||||||
|  | 			'↻ Show New: ' + | ||||||
|  | 			Object.keys(counts) | ||||||
|  | 				.sort() | ||||||
|  | 				.map((x) => counts[x].toString() + ' ' + x + 's') | ||||||
|  | 				.join(', ') | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	draft(event) { | 	draft(event) { | ||||||
| @@ -77,10 +84,7 @@ class TfTabNewsElement extends LitElement { | |||||||
| 		} else { | 		} else { | ||||||
| 			delete this.drafts[id]; | 			delete this.drafts[id]; | ||||||
| 		} | 		} | ||||||
| 		/* Only trigger a re-render if we're creating a new draft or discarding an old one. */ |  | ||||||
| 		if ((previous !== undefined) != (event.detail.draft !== undefined)) { |  | ||||||
| 		this.drafts = Object.assign({}, this.drafts); | 		this.drafts = Object.assign({}, this.drafts); | ||||||
| 		} |  | ||||||
| 		tfrpc.rpc.localStorageSet('drafts', JSON.stringify(this.drafts)); | 		tfrpc.rpc.localStorageSet('drafts', JSON.stringify(this.drafts)); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -96,23 +100,52 @@ class TfTabNewsElement extends LitElement { | |||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	on_keypress(event) { | 	on_keypress(event) { | ||||||
| 		if (event.target === document.body && | 		if (event.target === document.body && event.key == '.') { | ||||||
| 			event.key == '.') { |  | ||||||
| 			this.show_more(); | 			this.show_more(); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		let profile = this.hash.startsWith('#@') ? | 		let profile = this.hash.startsWith('#@') | ||||||
| 			html`<tf-profile id=${this.hash.substring(1)} whoami=${this.whoami} .users=${this.users}></tf-profile>` : undefined; | 			? html`<tf-profile | ||||||
|  | 					id=${this.hash.substring(1)} | ||||||
|  | 					whoami=${this.whoami} | ||||||
|  | 					.users=${this.users} | ||||||
|  | 				></tf-profile>` | ||||||
|  | 			: undefined; | ||||||
| 		return html` | 		return html` | ||||||
| 			<p class="w3-bar"> | 			<p class="w3-bar"> | ||||||
| 				<button class="w3-bar-item w3-button w3-dark-grey" @click=${this.show_more}>${this.new_messages_text()}</button> | 				<button | ||||||
|  | 					class="w3-bar-item w3-button w3-theme-d1" | ||||||
|  | 					@click=${this.show_more} | ||||||
|  | 				> | ||||||
|  | 					${this.new_messages_text()} | ||||||
|  | 				</button> | ||||||
| 			</p> | 			</p> | ||||||
| 			<div>Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!</div> | 			<div> | ||||||
| 			<div><tf-compose id="tf-compose" whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} @tf-draft=${this.draft}></tf-compose></div> | 				Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>! | ||||||
|  | 			</div> | ||||||
|  | 			<div> | ||||||
|  | 				<tf-compose | ||||||
|  | 					id="tf-compose" | ||||||
|  | 					whoami=${this.whoami} | ||||||
|  | 					.users=${this.users} | ||||||
|  | 					.drafts=${this.drafts} | ||||||
|  | 					@tf-draft=${this.draft} | ||||||
|  | 				></tf-compose> | ||||||
|  | 			</div> | ||||||
| 			${profile} | 			${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> | 			<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> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -41,7 +41,7 @@ class TfTabQueryElement extends LitElement { | |||||||
| 		await tfrpc.rpc.setHash('#sql=' + encodeURIComponent(query)); | 		await tfrpc.rpc.setHash('#sql=' + encodeURIComponent(query)); | ||||||
| 		let start_time = new Date(); | 		let start_time = new Date(); | ||||||
| 		try { | 		try { | ||||||
| 			this.results = await tfrpc.rpc.query(query, []) | 			this.results = await tfrpc.rpc.query(query, []); | ||||||
| 		} catch (error) { | 		} catch (error) { | ||||||
| 			this.error = error; | 			this.error = error; | ||||||
| 		} | 		} | ||||||
| @@ -79,8 +79,15 @@ class TfTabQueryElement extends LitElement { | |||||||
| 		} else { | 		} else { | ||||||
| 			let keys = Object.keys(this.results[0]).sort(); | 			let keys = Object.keys(this.results[0]).sort(); | ||||||
| 			return html`<table style="width: 100%; max-width: 100%"> | 			return html`<table style="width: 100%; max-width: 100%"> | ||||||
| 				<tr>${keys.map(key => html`<th>${key}</th>`)}</tr> | 				<tr> | ||||||
| 				${this.results.map(row => html`<tr>${keys.map(key => html`<td>${row[key]}</td>`)}</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>`; | 			</table>`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -100,13 +107,28 @@ class TfTabQueryElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` | ||||||
| 			<div style="display: flex; flex-direction: row; gap: 4px"> | 			<div style="display: flex; flex-direction: row; gap: 4px"> | ||||||
| 				<textarea id="search" rows=8 class="w3-input w3-dark-grey" style="flex: 1; resize: vertical" @keydown=${this.search_keydown}>${this.query}</textarea> | 				<textarea | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Execute</button> | 					id="search" | ||||||
|  | 					rows="8" | ||||||
|  | 					class="w3-input w3-theme-d1" | ||||||
|  | 					style="flex: 1; resize: vertical" | ||||||
|  | 					@keydown=${this.search_keydown} | ||||||
|  | 				> | ||||||
|  | ${this.query}</textarea | ||||||
|  | 				> | ||||||
|  | 				<button | ||||||
|  | 					class="w3-button w3-theme-d1" | ||||||
|  | 					@click=${(event) => | ||||||
|  | 						self.search(self.renderRoot.getElementById('search').value)} | ||||||
|  | 				> | ||||||
|  | 					Execute | ||||||
|  | 				</button> | ||||||
|  | 			</div> | ||||||
|  | 			<div ?hidden=${this.duration === undefined}> | ||||||
|  | 				Took ${this.duration / 1000.0} seconds. | ||||||
| 			</div> | 			</div> | ||||||
| 			<div ?hidden=${this.duration === undefined}>Took ${this.duration / 1000.0} seconds.</div> |  | ||||||
| 			<div ?hidden=${this.duration !== undefined}>Executing...</div> | 			<div ?hidden=${this.duration !== undefined}>Executing...</div> | ||||||
| 			${this.render_error()} | 			${this.render_error()} ${this.render_results()} | ||||||
| 			${this.render_results()} |  | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|   | |||||||
| @@ -33,14 +33,16 @@ class TfTabSearchElement extends LitElement { | |||||||
| 			search.select(); | 			search.select(); | ||||||
| 		} | 		} | ||||||
| 		await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query)); | 		await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query)); | ||||||
| 		let results = await tfrpc.rpc.query(` | 		let results = await tfrpc.rpc.query( | ||||||
| 				SELECT messages.* | 			` | ||||||
|  | 				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature | ||||||
| 				FROM messages_fts(?) | 				FROM messages_fts(?) | ||||||
| 				JOIN messages ON messages.rowid = messages_fts.rowid | 				JOIN messages ON messages.rowid = messages_fts.rowid | ||||||
| 				JOIN json_each(?) AS following ON messages.author = following.value | 				JOIN json_each(?) AS following ON messages.author = following.value | ||||||
| 				ORDER BY timestamp DESC limit 100 | 				ORDER BY timestamp DESC limit 100 | ||||||
| 			`, | 			`, | ||||||
| 			['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)]); | 			['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)] | ||||||
|  | 		); | ||||||
| 		console.log('Done.'); | 		console.log('Done.'); | ||||||
| 		search = this.renderRoot.getElementById('search'); | 		search = this.renderRoot.getElementById('search'); | ||||||
| 		if (search) { | 		if (search) { | ||||||
| @@ -76,8 +78,8 @@ class TfTabSearchElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` | ||||||
| 			<div style="display: flex; flex-direction: row; gap: 4px"> | 			<div style="display: flex; flex-direction: row; gap: 4px"> | ||||||
| 				<input type="text" class="w3-input w3-dark-grey" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input> | 				<input type="text" class="w3-input w3-theme-d1" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input> | ||||||
| 				<button class="w3-button w3-dark-grey" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Search</button> | 				<button class="w3-button w3-theme-d1" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Search</button> | ||||||
| 			</div> | 			</div> | ||||||
| 			<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users} .expanded=${this.expanded} @tf-expand=${this.on_expand}></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> | ||||||
| 		`; | 		`; | ||||||
|   | |||||||
| @@ -17,7 +17,11 @@ class TfTagElement extends LitElement { | |||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		let number = this.count ? html` (${this.count})` : undefined; | 		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>`; | 		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 | ||||||
|  | 		>`; | ||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|   | |||||||
| @@ -20,21 +20,24 @@ class TfUserElement extends LitElement { | |||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		let name = this.users?.[this.id]?.name; | 		let name = this.users?.[this.id]?.name; | ||||||
| 		name = name !== undefined ? | 		name = | ||||||
| 			html`<a target="_top" href=${'#' + this.id}>${name}</a>` : | 			name !== undefined | ||||||
| 			html`<a target="_top" href=${'#' + this.id}>${this.id}</a>`; | 				? html`<a target="_top" href=${'#' + this.id}>${name}</a>` | ||||||
|  | 				: html`<a target="_top" href=${'#' + this.id}>${this.id}</a>`; | ||||||
|  |  | ||||||
| 		if (this.users[this.id]) { | 		if (this.users[this.id]) { | ||||||
| 			let image = this.users[this.id].image; | 			let image = this.users[this.id].image; | ||||||
| 			image = typeof(image) == 'string' ? image : image?.link; | 			image = typeof image == 'string' ? image : image?.link; | ||||||
| 			return html` | 			return html` <div style="display: inline-block; font-weight: bold"> | ||||||
| 				<div style="display: inline-block; font-weight: bold"> | 				<img | ||||||
| 						<img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" ?hidden=${image === undefined} src="${image ? '/' + image + '/view' : undefined}"> | 					style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" | ||||||
|  | 					?hidden=${image === undefined} | ||||||
|  | 					src="${image ? '/' + image + '/view' : undefined}" | ||||||
|  | 				/> | ||||||
| 				${name} | 				${name} | ||||||
| 			</div>`; | 			</div>`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return html` | 			return html` <div style="display: inline-block; font-weight: bold"> | ||||||
| 				<div style="display: inline-block; font-weight: bold"> |  | ||||||
| 				${name} | 				${name} | ||||||
| 			</div>`; | 			</div>`; | ||||||
| 		} | 		} | ||||||
|   | |||||||
| @@ -1,21 +1,34 @@ | |||||||
| import * as linkify from './commonmark-linkify.js'; |  | ||||||
| import * as hashtagify from './commonmark-hashtag.js'; | import * as hashtagify from './commonmark-hashtag.js'; | ||||||
|  |  | ||||||
|  | const k_code_classes = 'w3-theme-l4 w3-theme-border w3-round'; | ||||||
|  |  | ||||||
| function image(node, entering) { | function image(node, entering) { | ||||||
| 	if (node.firstChild?.type === 'text' && | 	if ( | ||||||
| 		node.firstChild.literal.startsWith('video:')) { | 		node.firstChild?.type === 'text' && | ||||||
|  | 		node.firstChild.literal.startsWith('video:') | ||||||
|  | 	) { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>'); | 			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.lit('<source src="' + this.esc(node.destination) + '"></source>'); | ||||||
| 			this.disableTags += 1; | 			this.disableTags += 1; | ||||||
| 		} else { | 		} else { | ||||||
| 			this.disableTags -= 1; | 			this.disableTags -= 1; | ||||||
| 			this.lit('</video>'); | 			this.lit('</video>'); | ||||||
| 		} | 		} | ||||||
| 	} else if (node.firstChild?.type === 'text' && | 	} else if ( | ||||||
| 		node.firstChild.literal.startsWith('audio:')) { | 		node.firstChild?.type === 'text' && | ||||||
|  | 		node.firstChild.literal.startsWith('audio:') | ||||||
|  | 	) { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>'); | 			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.lit('<source src="' + this.esc(node.destination) + '"></source>'); | ||||||
| 			this.disableTags += 1; | 			this.disableTags += 1; | ||||||
| 		} else { | 		} else { | ||||||
| @@ -25,7 +38,11 @@ function image(node, entering) { | |||||||
| 	} else { | 	} else { | ||||||
| 		if (entering) { | 		if (entering) { | ||||||
| 			if (this.disableTags === 0) { | 			if (this.disableTags === 0) { | ||||||
| 				this.lit('<div class="img_caption">' + this.esc(node.firstChild?.literal || node.destination) + '</div>'); | 				this.lit( | ||||||
|  | 					'<div class="img_caption">' + | ||||||
|  | 						this.esc(node.firstChild?.literal || node.destination) + | ||||||
|  | 						'</div>' | ||||||
|  | 				); | ||||||
| 				if (this.options.safe && potentiallyUnsafe(node.destination)) { | 				if (this.options.safe && potentiallyUnsafe(node.destination)) { | ||||||
| 					this.lit('<img src="" alt="'); | 					this.lit('<img src="" alt="'); | ||||||
| 				} else { | 				} else { | ||||||
| @@ -45,12 +62,31 @@ function image(node, entering) { | |||||||
| 	} | 	} | ||||||
| } | } | ||||||
|  |  | ||||||
|  | function code(node) { | ||||||
|  | 	let attrs = this.attrs(node); | ||||||
|  | 	attrs.push(['class', k_code_classes]); | ||||||
|  | 	this.tag('code', attrs); | ||||||
|  | 	this.out(node.literal); | ||||||
|  | 	this.tag('/code'); | ||||||
|  | } | ||||||
|  |  | ||||||
|  | function attrs(node) { | ||||||
|  | 	let result = commonmark.HtmlRenderer.prototype.attrs.bind(this)(node); | ||||||
|  | 	if (node.type == 'block_quote') { | ||||||
|  | 		result.push(['class', 'w3-theme-d1']); | ||||||
|  | 	} else if (node.type == 'code_block') { | ||||||
|  | 		result.push(['class', k_code_classes]); | ||||||
|  | 	} | ||||||
|  | 	return result; | ||||||
|  | } | ||||||
|  |  | ||||||
| export function markdown(md) { | export function markdown(md) { | ||||||
| 	let reader = new commonmark.Parser({safe: true}); | 	let reader = new commonmark.Parser({safe: true}); | ||||||
| 	let writer = new commonmark.HtmlRenderer(); | 	let writer = new commonmark.HtmlRenderer(); | ||||||
| 	writer.image = image; | 	writer.image = image; | ||||||
|  | 	writer.code = code; | ||||||
|  | 	writer.attrs = attrs; | ||||||
| 	let parsed = reader.parse(md || ''); | 	let parsed = reader.parse(md || ''); | ||||||
| 	parsed = linkify.transform(parsed); |  | ||||||
| 	parsed = hashtagify.transform(parsed); | 	parsed = hashtagify.transform(parsed); | ||||||
| 	let walker = parsed.walker(); | 	let walker = parsed.walker(); | ||||||
| 	let event, node; | 	let event, node; | ||||||
| @@ -58,14 +94,20 @@ export function markdown(md) { | |||||||
| 		node = event.node; | 		node = event.node; | ||||||
| 		if (event.entering) { | 		if (event.entering) { | ||||||
| 			if (node.type == 'link') { | 			if (node.type == 'link') { | ||||||
| 				if (node.destination.startsWith('@') && | 				if ( | ||||||
| 					node.destination.endsWith('.ed25519')) { | 					node.destination.startsWith('@') && | ||||||
|  | 					node.destination.endsWith('.ed25519') | ||||||
|  | 				) { | ||||||
| 					node.destination = '#' + node.destination; | 					node.destination = '#' + node.destination; | ||||||
| 				} else if (node.destination.startsWith('%') && | 				} else if ( | ||||||
| 					node.destination.endsWith('.sha256')) { | 					node.destination.startsWith('%') && | ||||||
|  | 					node.destination.endsWith('.sha256') | ||||||
|  | 				) { | ||||||
| 					node.destination = '#' + node.destination; | 					node.destination = '#' + node.destination; | ||||||
| 				} else if (node.destination.startsWith('&') && | 				} else if ( | ||||||
| 					node.destination.endsWith('.sha256')) { | 					node.destination.startsWith('&') && | ||||||
|  | 					node.destination.endsWith('.sha256') | ||||||
|  | 				) { | ||||||
| 					node.destination = '/' + node.destination + '/view'; | 					node.destination = '/' + node.destination + '/view'; | ||||||
| 				} | 				} | ||||||
| 			} else if (node.type == 'image') { | 			} else if (node.type == 'image') { | ||||||
|   | |||||||
| @@ -27,7 +27,8 @@ async function todo_add(list) { | |||||||
| 		let set = new Set(names); | 		let set = new Set(names); | ||||||
| 		set.add(list); | 		set.add(list); | ||||||
| 		names = JSON.stringify([...set].sort()); | 		names = JSON.stringify([...set].sort()); | ||||||
| 		exchanged = original === names || await g_db.exchange('files', original, names); | 		exchanged = | ||||||
|  | 			original === names || (await g_db.exchange('files', original, names)); | ||||||
| 	} | 	} | ||||||
| 	return exchanged; | 	return exchanged; | ||||||
| } | } | ||||||
| @@ -42,7 +43,8 @@ async function todo_remove(list) { | |||||||
| 		let set = new Set(names); | 		let set = new Set(names); | ||||||
| 		set.delete(list); | 		set.delete(list); | ||||||
| 		names = JSON.stringify([...set].sort()); | 		names = JSON.stringify([...set].sort()); | ||||||
| 		exchanged = original === names || await g_db.exchange('files', original, names); | 		exchanged = | ||||||
|  | 			original === names || (await g_db.exchange('files', original, names)); | ||||||
| 	} | 	} | ||||||
| 	await g_db.remove('list:' + list); | 	await g_db.remove('list:' + list); | ||||||
| 	return exchanged; | 	return exchanged; | ||||||
|   | |||||||
| @@ -1,4 +1,4 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html> | <html> | ||||||
| 	<head> | 	<head> | ||||||
| 		<title>TODO</title> | 		<title>TODO</title> | ||||||
|   | |||||||
| @@ -4,7 +4,7 @@ import * as tfrpc from '/static/tfrpc.js'; | |||||||
| class TodosElement extends LitElement { | class TodosElement extends LitElement { | ||||||
| 	static get properties() { | 	static get properties() { | ||||||
| 		return { | 		return { | ||||||
| 			lists: {type: Array} | 			lists: {type: Array}, | ||||||
| 		}; | 		}; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -12,9 +12,12 @@ class TodosElement extends LitElement { | |||||||
| 		super(); | 		super(); | ||||||
| 		this.lists = []; | 		this.lists = []; | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		tfrpc.rpc.todo_get_all().then(function(lists) { | 		tfrpc.rpc | ||||||
|  | 			.todo_get_all() | ||||||
|  | 			.then(function (lists) { | ||||||
| 				self.lists = lists; | 				self.lists = lists; | ||||||
| 		}).catch(function(error) { | 			}) | ||||||
|  | 			.catch(function (error) { | ||||||
| 				console.log(error); | 				console.log(error); | ||||||
| 			}); | 			}); | ||||||
| 	} | 	} | ||||||
| @@ -32,9 +35,15 @@ class TodosElement extends LitElement { | |||||||
| 		return html` | 		return html` | ||||||
| 			<div> | 			<div> | ||||||
| 				<div style="display: flex"> | 				<div style="display: flex"> | ||||||
| 					${this.lists.map(x => html` | 					${this.lists.map( | ||||||
| 						<tf-todo-list name=${x.name} .items=${x.items} @change=${this.refresh}></tf-todo-list> | 						(x) => html` | ||||||
| 					`)} | 							<tf-todo-list | ||||||
|  | 								name=${x.name} | ||||||
|  | 								.items=${x.items} | ||||||
|  | 								@change=${this.refresh} | ||||||
|  | 							></tf-todo-list> | ||||||
|  | 						` | ||||||
|  | 					)} | ||||||
| 				</div> | 				</div> | ||||||
| 				<input type="button" @click=${this.new_list} value="+ List"></input> | 				<input type="button" @click=${this.new_list} value="+ List"></input> | ||||||
| 			</div>`; | 			</div>`; | ||||||
| @@ -59,16 +68,22 @@ class TodoListElement extends LitElement { | |||||||
| 	save() { | 	save() { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		console.log('saving', self.name, self.items); | 		console.log('saving', self.name, self.items); | ||||||
| 		tfrpc.rpc.todo_set(self.name, self.items).then(function() { | 		tfrpc.rpc | ||||||
|  | 			.todo_set(self.name, self.items) | ||||||
|  | 			.then(function () { | ||||||
| 				console.log('saved', self.name, self.items); | 				console.log('saved', self.name, self.items); | ||||||
| 		}).catch(function(error) { | 			}) | ||||||
|  | 			.catch(function (error) { | ||||||
| 				console.log(error); | 				console.log(error); | ||||||
| 			}); | 			}); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	remove_item(item) { | 	remove_item(item) { | ||||||
| 		let index = this.items.indexOf(item); | 		let index = this.items.indexOf(item); | ||||||
| 		this.items = [].concat(this.items.slice(0, index), this.items.slice(index + 1)); | 		this.items = [].concat( | ||||||
|  | 			this.items.slice(0, index), | ||||||
|  | 			this.items.slice(index + 1) | ||||||
|  | 		); | ||||||
| 		this.save(); | 		this.save(); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| @@ -106,20 +121,20 @@ class TodoListElement extends LitElement { | |||||||
| 		let self = this; | 		let self = this; | ||||||
| 		if (index === this.editing) { | 		if (index === this.editing) { | ||||||
| 			return html` | 			return html` | ||||||
| 				<div><input type="checkbox" ?checked=${item.x} @change=${x => self.handle_check(x, item)}></input> | 				<div><input type="checkbox" ?checked=${item.x} @change=${(x) => self.handle_check(x, item)}></input> | ||||||
| 				<input | 				<input | ||||||
| 					id="edit" | 					id="edit" | ||||||
| 					type="text" | 					type="text" | ||||||
| 					value=${item.text} | 					value=${item.text} | ||||||
| 					@change=${event => self.input_change(event, item)} | 					@change=${(event) => self.input_change(event, item)} | ||||||
| 					@keydown=${event => self.input_keydown(event, item)} | 					@keydown=${(event) => self.input_keydown(event, item)} | ||||||
| 					@blur=${x => self.input_blur(item)}></input> | 					@blur=${(x) => self.input_blur(item)}></input> | ||||||
| 				<span @click=${x => self.remove_item(item)} style="cursor: pointer">❎</span></div> | 				<span @click=${(x) => self.remove_item(item)} style="cursor: pointer">❎</span></div> | ||||||
| 			`; | 			`; | ||||||
| 		} else { | 		} else { | ||||||
| 			return html` | 			return html` | ||||||
| 				<div><input type="checkbox" ?checked=${item.x} @change=${x => self.handle_check(x, item)}></input> | 				<div><input type="checkbox" ?checked=${item.x} @change=${(x) => self.handle_check(x, item)}></input> | ||||||
| 				<span @click=${x => self.editing = index}>${item.text || '(empty)'}</span> | 				<span @click=${(x) => (self.editing = index)}>${item.text || '(empty)'}</span> | ||||||
| 			`; | 			`; | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
| @@ -139,10 +154,13 @@ class TodoListElement extends LitElement { | |||||||
|  |  | ||||||
| 	rename(new_name) { | 	rename(new_name) { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return tfrpc.rpc.todo_rename(this.name, new_name).then(function() { | 		return tfrpc.rpc | ||||||
|  | 			.todo_rename(this.name, new_name) | ||||||
|  | 			.then(function () { | ||||||
| 				self.dispatchEvent(new Event('change')); | 				self.dispatchEvent(new Event('change')); | ||||||
| 				self.editing_name = false; | 				self.editing_name = false; | ||||||
| 		}).catch(function(error) { | 			}) | ||||||
|  | 			.catch(function (error) { | ||||||
| 				console.log(error); | 				console.log(error); | ||||||
| 				alert(error.message); | 				alert(error.message); | ||||||
| 				self.editing_name = false; | 				self.editing_name = false; | ||||||
| @@ -163,19 +181,25 @@ class TodoListElement extends LitElement { | |||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		let name = this.editing_name ? | 		let name = this.editing_name | ||||||
| 			html`<input | 			? html`<input | ||||||
| 				type="text" | 				type="text" | ||||||
| 				id="edit" | 				id="edit" | ||||||
| 				@keydown=${event => self.name_keydown(event)} | 				@keydown=${(event) => self.name_keydown(event)} | ||||||
| 				@blur=${event => self.name_blur(event.srcElement.value)} | 				@blur=${(event) => self.name_blur(event.srcElement.value)} | ||||||
| 				value=${this.name}></input>` : | 				value=${this.name}></input>` | ||||||
| 			html`<h2 @click=${x => this.editing_name = true}>${this.name}</h2>`; | 			: html`<h2 @click=${(x) => (this.editing_name = true)}>${this.name}</h2>`; | ||||||
| 		return html` | 		return html` | ||||||
| 			<div style="border: 3px solid black; padding: 8px; margin: 8px; border-radius: 8px; background-color: #444"> | 			<div | ||||||
|  | 				style="border: 3px solid black; padding: 8px; margin: 8px; border-radius: 8px; background-color: #444" | ||||||
|  | 			> | ||||||
| 				${name} | 				${name} | ||||||
| 				${(this.items || []).filter(item => !item.x).map(x => self.render_item(x))} | 				${(this.items || []) | ||||||
| 				${(this.items || []).filter(item => item.x).map(x => self.render_item(x))} | 					.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.add_item}>+ Item</button> | ||||||
| 				<button @click=${self.remove_list}>- List</button> | 				<button @click=${self.remove_list}>- List</button> | ||||||
| 			</div> | 			</div> | ||||||
|   | |||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
| 	"type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
| 	"emoji": "👋", | 	"emoji": "👋", | ||||||
|   "previous": "&zFISmRDAv+SXFonfZ9/sHNhrmMe+poTU22gwZzuSkT4=.sha256" | 	"previous": "&W5aJp2DgOW5rQ0AOIC9Ut3DpsahPrO6PjkJ1PQbNRdM=.sha256" | ||||||
| } | } | ||||||
| @@ -1,23 +1,36 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html> | <html> | ||||||
| 	<head> | 	<head> | ||||||
| 		<meta charset="UTF-8"> | 		<meta charset="UTF-8" /> | ||||||
| 		<meta name="viewport" content="width=device-width, initial-scale=1"> | 		<meta name="viewport" content="width=device-width, initial-scale=1" /> | ||||||
| 		<link rel="stylesheet" href="w3.css"> | 		<link rel="stylesheet" href="w3.css" /> | ||||||
| 		<link rel="stylesheet" href="fontawesome.min.css"> | 		<link rel="stylesheet" href="fontawesome.min.css" /> | ||||||
| 		<link rel="stylesheet" href="regular.min.css"> | 		<link rel="stylesheet" href="regular.min.css" /> | ||||||
| 		<link rel="stylesheet" href="solid.min.css"> | 		<link rel="stylesheet" href="solid.min.css" /> | ||||||
| 		<link rel="stylesheet" href="brands.min.css"> | 		<link rel="stylesheet" href="brands.min.css" /> | ||||||
|  |  | ||||||
| 		<style> | 		<style> | ||||||
| 			body,h1,h2,h3,h4,h5 {font-family: "Poppins", sans-serif} | 			body, | ||||||
| 			body {font-size: 16px;} | 			h1, | ||||||
| 			img {margin-bottom: -8px;} | 			h2, | ||||||
| 			.mySlides {display: none;} | 			h3, | ||||||
|  | 			h4, | ||||||
|  | 			h5 { | ||||||
|  | 				font-family: 'Poppins', sans-serif; | ||||||
|  | 			} | ||||||
|  | 			body { | ||||||
|  | 				font-size: 16px; | ||||||
|  | 			} | ||||||
|  | 			img { | ||||||
|  | 				margin-bottom: -8px; | ||||||
|  | 			} | ||||||
|  | 			.mySlides { | ||||||
|  | 				display: none; | ||||||
|  | 			} | ||||||
| 		</style> | 		</style> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body class="w3-content w3-black" style="max-width:1500px;"> | 	<body class="w3-content w3-black" style="max-width: 1500px"> | ||||||
| 		<!-- The App Section --> | 		<!-- The App Section --> | ||||||
| 		<div class="w3-padding-64 w3-white"> | 		<div class="w3-padding-64 w3-white"> | ||||||
| 			<div class="w3-row-padding"> | 			<div class="w3-row-padding"> | ||||||
| @@ -25,20 +38,93 @@ | |||||||
| 					<h1 class="w3-jumbo"> | 					<h1 class="w3-jumbo"> | ||||||
| 						<b>😎 Tilde Friends</b> | 						<b>😎 Tilde Friends</b> | ||||||
| 					</h1> | 					</h1> | ||||||
| 					<h1 class="w3-xxlarge w3-text-green"><b>Make apps and friends from the comfort of your web browser.</b></h1> | 					<h1 class="w3-xxlarge w3-text-green"> | ||||||
| 					<p>Tilde Friends is a platform for building, running, and sharing web applications.</p> | 						<b>Make apps and friends from the comfort of your web browser.</b> | ||||||
| 					<p>Available for lots of devices: | 					</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-linux w3-xlarge"></i> | ||||||
| 						<i class="fa-brands fa-android w3-xlarge"></i> | 						<i class="fa-brands fa-android w3-xlarge"></i> | ||||||
| 						<i class="fa-brands fa-apple w3-xlarge"></i> | 						<i class="fa-brands fa-apple w3-xlarge"></i> | ||||||
| 						<i class="fa fa-mobile-screen w3-xlarge"></i> | 						<i class="fa fa-mobile-screen w3-xlarge"></i> | ||||||
| 						<i class="fa-brands fa-windows w3-xlarge"></i> | 						<i class="fa-brands fa-windows w3-xlarge"></i> | ||||||
| 					</p> | 					</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 | ||||||
| 					<a class="w3-button w3-black w3-padding-large" href="https://www.tildefriends.net/~cory/apps/"><i class="fa fa-link"></i> Try It</a> | 						class="w3-button w3-black w3-padding-large" | ||||||
|  | 						href="https://dev.tildefriends.net/cory/tildefriends/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 It</a | ||||||
|  | 					> | ||||||
|  | 					<a | ||||||
|  | 						class="w3-button w3-black w3-padding-large" | ||||||
|  | 						href="https://dev.tildefriends.net/" | ||||||
|  | 						><i class="fa fa-mug-hot"></i> Code</a | ||||||
|  | 					> | ||||||
| 				</div> | 				</div> | ||||||
| 				<div class="w3-col l4 m6"> | 				<div class="w3-col l4 m6"> | ||||||
| 					<img src="tildefriends.png" class="w3-image w3-right w3-hide-small"> | 					<img src="tildefriends.png" class="w3-image w3-right w3-hide-small" /> | ||||||
|  | 				</div> | ||||||
|  | 			</div> | ||||||
|  | 		</div> | ||||||
|  |  | ||||||
|  | 		<!-- Getting Starting Section --> | ||||||
|  | 		<div class="w3-indigo w3-center"> | ||||||
|  | 			<div class="w3-row-padding w3-padding-64"> | ||||||
|  | 				<div class="w3-jumbo"> | ||||||
|  | 					<i class="fa fa-rocket"></i> <b>Getting Started</b> | ||||||
|  | 				</div> | ||||||
|  | 				<div> | ||||||
|  | 					<h2>First-time user checklist:</h2> | ||||||
|  | 					<ol type="1" style="text-align: left"> | ||||||
|  | 						<li> | ||||||
|  | 							<a href="https://dev.tildefriends.net/cory/tildefriends/releases" | ||||||
|  | 								>Download</a | ||||||
|  | 							> | ||||||
|  | 							Tilde Friends and run your own instance, or use | ||||||
|  | 							<a href="https://www.tildefriends.net/" | ||||||
|  | 								>https://www.tildefriends.net/</a | ||||||
|  | 							>. | ||||||
|  | 						</li> | ||||||
|  | 						<li> | ||||||
|  | 							Create an account to identify yourself with that instance by | ||||||
|  | 							username and password. | ||||||
|  | 						</li> | ||||||
|  | 						<li> | ||||||
|  | 							Create an SSB identity in the <b>ssb</b> app. This will generate a | ||||||
|  | 							keypair used to identify yourself to other users and sign your | ||||||
|  | 							messages so that they can be verified as from you. | ||||||
|  | 						</li> | ||||||
|  | 						<li> | ||||||
|  | 							Describe yourself in your profile in the <b>ssb</b> app. Give | ||||||
|  | 							yourself a name and an avatar if you like. | ||||||
|  | 						</li> | ||||||
|  | 						<li> | ||||||
|  | 							Connect to others. You will automatically discover peers on the | ||||||
|  | 							same instance and same network if there are any. Or use | ||||||
|  | 							<a href="https://github.com/staltz/ssb-room/blob/master/FAQ.md" | ||||||
|  | 								>rooms</a | ||||||
|  | 							> | ||||||
|  | 							and pubs to reach more distant users. | ||||||
|  | 							<a href="https://www.tildefriends.net/~cory/room/" | ||||||
|  | 								>tildefriends.net itself</a | ||||||
|  | 							> | ||||||
|  | 							operates as a room, so you can connect and see who else is online | ||||||
|  | 							and establish a connection. | ||||||
|  | 						</li> | ||||||
|  | 						<li>Follow people to grow your network.</li> | ||||||
|  | 						<li> | ||||||
|  | 							Use the <b>edit</b> link at the top of any page to start modifying | ||||||
|  | 							and making apps. | ||||||
|  | 						</li> | ||||||
|  | 					</ol> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
| @@ -47,19 +133,28 @@ | |||||||
| 		<div class="w3-light-grey"> | 		<div class="w3-light-grey"> | ||||||
| 			<div class="w3-row-padding w3-padding-64"> | 			<div class="w3-row-padding w3-padding-64"> | ||||||
| 				<div class="w3-col l4 m6 s4"> | 				<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> | 					<a href="https://scuttlebutt.nz/" | ||||||
|  | 						><img | ||||||
|  | 							class="w3-image w3-round-large" | ||||||
|  | 							src="ssb.png" | ||||||
|  | 							alt="Secure Scuttlebutt" | ||||||
|  | 					/></a> | ||||||
| 				</div> | 				</div> | ||||||
| 				<div class="w3-col l8 m6" style="height: auto"> | 				<div class="w3-col l8 m6" style="height: auto"> | ||||||
| 					<h1 class="w3-jumbo"><b>Built for Sharing</b></h1> | 					<h1 class="w3-jumbo"><b>Built for Sharing</b></h1> | ||||||
| 					<p> | 					<p> | ||||||
| 						Tilde Friends participates in the <a href="https://scuttlebutt.nz/">Secure Scuttlebutt</a> distributed social network. | 						Tilde Friends participates in the | ||||||
|  | 						<a href="https://scuttlebutt.nz/">Secure Scuttlebutt</a> distributed | ||||||
|  | 						social network. | ||||||
| 					</p> | 					</p> | ||||||
| 					<p> | 					<p> | ||||||
| 						Share apps with friends.  Discover new apps made by enemies.  Post pictures of your coffee.  Or just lurk. | 						Share apps with friends. Discover new apps made by enemies. Post | ||||||
|  | 						pictures of your coffee. Or just lurk. | ||||||
| 					</p> | 					</p> | ||||||
| 					<p> | 					<p> | ||||||
| 						The social network integration provides tools for connecting with other people world-wide | 						The social network integration provides tools for connecting with | ||||||
| 						while still allowing apps and everything to operate offline. | 						other people world-wide while still allowing apps and everything to | ||||||
|  | 						operate offline. | ||||||
| 					</p> | 					</p> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| @@ -70,14 +165,16 @@ | |||||||
| 			<div class="w3-row-padding"> | 			<div class="w3-row-padding"> | ||||||
| 				<div class="w3-col l8 m6"> | 				<div class="w3-col l8 m6"> | ||||||
| 					<h1 class="w3-jumbo"><b>Edit Anything</b></h1> | 					<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> | 					<i | ||||||
|  | 						class="fa fa-pen-to-square w3-left w3-jumbo w3-text-gray" | ||||||
|  | 						style="padding: 32px" | ||||||
|  | 					></i> | ||||||
| 					<p> | 					<p> | ||||||
| 						See that <code><b>edit</b></code> link near the top left corner of this page?  It's there for | 						See that <code><b>edit</b></code> link near the top left corner of | ||||||
| 						every Tilde Friends app, so you can modify and see your changes right away. | 						this page? It's there for every Tilde Friends app, so you can modify | ||||||
| 					</p> | 						and see your changes right away. | ||||||
| 					<p> |  | ||||||
| 						It's kind of like a wiki, but for code! |  | ||||||
| 					</p> | 					</p> | ||||||
|  | 					<p>It's kind of like a wiki, but for code!</p> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| 		</div> | 		</div> | ||||||
| @@ -86,16 +183,22 @@ | |||||||
| 		<div class="w3-padding-64 w3-grey"> | 		<div class="w3-padding-64 w3-grey"> | ||||||
| 			<div class="w3-row-padding"> | 			<div class="w3-row-padding"> | ||||||
| 				<div class="w3-col"> | 				<div class="w3-col"> | ||||||
| 					<h1 class="w3-jumbo" style="text-align: right"><b>Sandbox Security</b></h1> | 					<h1 class="w3-jumbo" style="text-align: right"> | ||||||
| 					<i class="fa fa-road-barrier w3-right w3-jumbo w3-text-yellow" style="padding: 32px"></i> | 						<b>Sandbox Security</b> | ||||||
|  | 					</h1> | ||||||
|  | 					<i | ||||||
|  | 						class="fa fa-road-barrier w3-right w3-jumbo w3-text-yellow" | ||||||
|  | 						style="padding: 32px" | ||||||
|  | 					></i> | ||||||
| 					<p> | 					<p> | ||||||
| 						Tilde Friends tries to make sure apps can be trusted using similar techniques to how web | 						Tilde Friends tries to make sure apps can be trusted using similar | ||||||
| 						browsers and operating systems do it. | 						techniques to how web browsers and operating systems do it. | ||||||
| 					</p> | 					</p> | ||||||
| 					<p> | 					<p> | ||||||
| 						This is all a work in progress, and it varies by platform, so don't give it all your | 						This is all a work in progress, and it varies by platform, so don't | ||||||
| 						innermost secrets yet, but do kick its tires and | 						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. | 						<a href="mailto:cory@tildefriends.net">share</a> any surprises you | ||||||
|  | 						find. | ||||||
| 					</p> | 					</p> | ||||||
| 				</div> | 				</div> | ||||||
| 			</div> | 			</div> | ||||||
| @@ -105,10 +208,16 @@ | |||||||
| 		<div class="w3-container w3-padding-64 w3-light-grey w3-center"> | 		<div class="w3-container w3-padding-64 w3-light-grey w3-center"> | ||||||
| 			<h1 class="w3-jumbo"><b>Trusted Technology</b></h1> | 			<h1 class="w3-jumbo"><b>Trusted Technology</b></h1> | ||||||
| 			<p>Tilde Friends is built using boring, trusted tech.</p> | 			<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> | 			<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"> | 			<div class="w3-row" style="margin-top: 64px"> | ||||||
| 				<a href="https://en.wikipedia.org/wiki/C_(programming_language)" class="w3-col s3"> | 				<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> | 					<i class="fa fa-c w3-text-blue w3-jumbo"></i> | ||||||
| 					<p>C</p> | 					<p>C</p> | ||||||
| 				</a> | 				</a> | ||||||
| @@ -139,14 +248,17 @@ | |||||||
| 					<i class="fa fa-shield-halved w3-text-green w3-jumbo"></i> | 					<i class="fa fa-shield-halved w3-text-green w3-jumbo"></i> | ||||||
| 					<p>OpenSSL</p> | 					<p>OpenSSL</p> | ||||||
| 				</a> | 				</a> | ||||||
| 				<a href="https://github.com/ianlancetaylor/libbacktrace" class="w3-col s3"> | 				<a | ||||||
|  | 					href="https://github.com/ianlancetaylor/libbacktrace" | ||||||
|  | 					class="w3-col s3" | ||||||
|  | 				> | ||||||
| 					<i class="fa fa-burst w3-text-pink w3-jumbo"></i> | 					<i class="fa fa-burst w3-text-pink w3-jumbo"></i> | ||||||
| 					<p>libbacktrace</p> | 					<p>libbacktrace</p> | ||||||
| 				</a> | 				</a> | ||||||
| 			</div> | 			</div> | ||||||
|  |  | ||||||
| 			<div class="w3-row" style="margin-top: 64px"> | 			<div class="w3-row" style="margin-top: 64px"> | ||||||
| 				<a href="https://codemirror.net/5/" class="w3-col s3"> | 				<a href="https://codemirror.net/docs/changelog/" class="w3-col s3"> | ||||||
| 					<i class="fa fa-keyboard w3-text-indigo w3-jumbo"></i> | 					<i class="fa fa-keyboard w3-text-indigo w3-jumbo"></i> | ||||||
| 					<p>CodeMirror</p> | 					<p>CodeMirror</p> | ||||||
| 				</a> | 				</a> | ||||||
| @@ -167,7 +279,10 @@ | |||||||
|  |  | ||||||
| 		<!-- Footer --> | 		<!-- Footer --> | ||||||
| 		<footer class="w3-container w3-padding-32 w3-blue-grey w3-center w3-xlarge"> | 		<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> | 			<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> | 		</footer> | ||||||
| 	</body> | 	</body> | ||||||
| </html> | </html> | ||||||
| @@ -1,5 +1,5 @@ | |||||||
| { | { | ||||||
| 	"type": "tildefriends-app", | 	"type": "tildefriends-app", | ||||||
| 	"emoji": "📝", | 	"emoji": "📝", | ||||||
|   "previous": "&qrcrBeaWg89ikgql9hXdr68krkg+5NZkmwTbpodEW4U=.sha256" | 	"previous": "&DaYqKHRBKhjFGaOzbKZ1+/pLspJeEkDJYTF2B50tH6k=.sha256" | ||||||
| } | } | ||||||
| @@ -4,6 +4,9 @@ import * as utils from './utils.js'; | |||||||
| let g_hash; | let g_hash; | ||||||
| let g_collection_notifies = {}; | let g_collection_notifies = {}; | ||||||
|  |  | ||||||
|  | tfrpc.register(async function getActiveIdentity() { | ||||||
|  | 	return ssb.getActiveIdentity(); | ||||||
|  | }); | ||||||
| tfrpc.register(async function getOwnerIdentities() { | tfrpc.register(async function getOwnerIdentities() { | ||||||
| 	return ssb.getOwnerIdentities(); | 	return ssb.getOwnerIdentities(); | ||||||
| }); | }); | ||||||
| @@ -54,6 +57,9 @@ core.register('message', async function message_handler(message) { | |||||||
| 		await tfrpc.rpc.hash_changed(message.hash); | 		await tfrpc.rpc.hash_changed(message.hash); | ||||||
| 	} | 	} | ||||||
| }); | }); | ||||||
|  | core.register('setActiveIdentity', async function setActiveIdentityHandler(id) { | ||||||
|  | 	await tfrpc.rpc.setActiveIdentity(id); | ||||||
|  | }); | ||||||
|  |  | ||||||
| tfrpc.register(function set_hash(hash) { | tfrpc.register(function set_hash(hash) { | ||||||
| 	if (g_hash != hash) { | 	if (g_hash != hash) { | ||||||
|   | |||||||
| @@ -10,9 +10,14 @@ function markdown(md) { | |||||||
| 	while ((event = walker.next())) { | 	while ((event = walker.next())) { | ||||||
| 		let node = event.node; | 		let node = event.node; | ||||||
| 		if (event.entering) { | 		if (event.entering) { | ||||||
| 			if (node.type === 'link') { | 			if (node.destination?.startsWith('&')) { | ||||||
| 				if (node.destination.indexOf(':') == -1 && | 				node.destination = | ||||||
| 					node.destination.indexOf('/') == -1) { | 					'/' + node.destination + '/view?filename=' + node.firstChild?.literal; | ||||||
|  | 			} else if (node.type === 'link') { | ||||||
|  | 				if ( | ||||||
|  | 					node.destination.indexOf(':') == -1 && | ||||||
|  | 					node.destination.indexOf('/') == -1 | ||||||
|  | 				) { | ||||||
| 					node.destination = `${node.destination}`; | 					node.destination = `${node.destination}`; | ||||||
| 				} | 				} | ||||||
| 			} | 			} | ||||||
| @@ -27,7 +32,9 @@ async function main() { | |||||||
| 		let wiki_name = request.path.substring(0, slash); | 		let wiki_name = request.path.substring(0, slash); | ||||||
| 		let wiki_doc_name = request.path.substring(slash + 1); | 		let wiki_doc_name = request.path.substring(slash + 1); | ||||||
|  |  | ||||||
| 		let ids = Object.keys(await ssb.following(await ssb.getOwnerIdentities(), 1)); | 		let ids = Object.keys( | ||||||
|  | 			await ssb.following(await ssb.getOwnerIdentities(), 1) | ||||||
|  | 		); | ||||||
| 		let [max_row_id, wikis] = await utils.collection(ids, 'wiki', null, -1, {}); | 		let [max_row_id, wikis] = await utils.collection(ids, 'wiki', null, -1, {}); | ||||||
| 		let wiki; | 		let wiki; | ||||||
| 		for (let w of Object.values(wikis)) { | 		for (let w of Object.values(wikis)) { | ||||||
| @@ -38,7 +45,13 @@ async function main() { | |||||||
| 		} | 		} | ||||||
| 		let wiki_doc; | 		let wiki_doc; | ||||||
| 		if (wiki) { | 		if (wiki) { | ||||||
| 			let [max_row_id, wiki_docs] = await utils.collection(ids, 'wiki-doc', wiki.id, -1, {}); | 			let [max_row_id, wiki_docs] = await utils.collection( | ||||||
|  | 				ids, | ||||||
|  | 				'wiki-doc', | ||||||
|  | 				wiki.id, | ||||||
|  | 				-1, | ||||||
|  | 				{} | ||||||
|  | 			); | ||||||
| 			for (let w of Object.values(wiki_docs)) { | 			for (let w of Object.values(wiki_docs)) { | ||||||
| 				if (w.name === wiki_doc_name && !w.tombstone) { | 				if (w.name === wiki_doc_name && !w.tombstone) { | ||||||
| 					wiki_doc = w; | 					wiki_doc = w; | ||||||
| @@ -57,7 +70,7 @@ async function main() { | |||||||
| 				<h1>${wiki_name}: ${wiki_doc_name}</h1> | 				<h1>${wiki_name}: ${wiki_doc_name}</h1> | ||||||
| 				<div>${markdown(md)}</div> | 				<div>${markdown(md)}</div> | ||||||
| 			`, | 			`, | ||||||
| 			content_type: 'text/html', | 			content_type: 'text/html; charset=utf-8', | ||||||
| 			status_code: 200, | 			status_code: 200, | ||||||
| 		}); | 		}); | ||||||
| 	} else { | 	} else { | ||||||
|   | |||||||
| @@ -1,13 +1,15 @@ | |||||||
| <!DOCTYPE html> | <!doctype html> | ||||||
| <html> | <html> | ||||||
| 	<head> | 	<head> | ||||||
| 		<base target="_top"> | 		<base target="_top" /> | ||||||
|  | 		<link rel="stylesheet" href="tildefriends.css" /> | ||||||
| 	</head> | 	</head> | ||||||
| 	<body style="color: #fff"> | 	<body> | ||||||
| 		<tf-collections-app></tf-collections-app> | 		<tf-collections-app></tf-collections-app> | ||||||
| 		<script>window.litDisableBundleWarning = true;</script> | 		<script> | ||||||
|  | 			window.litDisableBundleWarning = true; | ||||||
|  | 		</script> | ||||||
| 		<script src="tf-collection.js" type="module"></script> | 		<script src="tf-collection.js" type="module"></script> | ||||||
| 		<script src="tf-id-picker.js" type="module"></script> |  | ||||||
| 		<script src="tf-wiki-doc.js" type="module"></script> | 		<script src="tf-wiki-doc.js" type="module"></script> | ||||||
| 		<script src="tf-wiki-app.js" type="module"></script> | 		<script src="tf-wiki-app.js" type="module"></script> | ||||||
| 	</body> | 	</body> | ||||||
|   | |||||||
							
								
								
									
										22
									
								
								apps/wiki/lit-all.min.js
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										22
									
								
								apps/wiki/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
											
										
									
								
							| @@ -5,6 +5,7 @@ class TfCollectionElement extends LitElement { | |||||||
| 	static get properties() { | 	static get properties() { | ||||||
| 		return { | 		return { | ||||||
| 			whoami: {type: String}, | 			whoami: {type: String}, | ||||||
|  | 			category: {type: String}, | ||||||
| 			collection: {type: Object}, | 			collection: {type: Object}, | ||||||
| 			selected_id: {type: String}, | 			selected_id: {type: String}, | ||||||
| 			is_creating: {type: Boolean}, | 			is_creating: {type: Boolean}, | ||||||
| @@ -14,79 +15,100 @@ class TfCollectionElement extends LitElement { | |||||||
|  |  | ||||||
| 	on_create(event) { | 	on_create(event) { | ||||||
| 		let name = this.shadowRoot.getElementById('create_name').value; | 		let name = this.shadowRoot.getElementById('create_name').value; | ||||||
| 		this.dispatchEvent(new CustomEvent('create', { | 		this.dispatchEvent( | ||||||
|  | 			new CustomEvent('create', { | ||||||
| 				bubbles: true, | 				bubbles: true, | ||||||
| 				detail: { | 				detail: { | ||||||
| 					name: name, | 					name: name, | ||||||
| 				}, | 				}, | ||||||
| 		})); | 			}) | ||||||
|  | 		); | ||||||
| 		this.is_creating = false; | 		this.is_creating = false; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	on_rename(event) { | 	on_rename(event) { | ||||||
| 		let id = this.shadowRoot.getElementById('select').value; | 		let id = this.shadowRoot.getElementById('select').value; | ||||||
| 		let name = this.shadowRoot.getElementById('rename_name').value; | 		let name = this.shadowRoot.getElementById('rename_name').value; | ||||||
| 		this.dispatchEvent(new CustomEvent('rename', { | 		this.dispatchEvent( | ||||||
|  | 			new CustomEvent('rename', { | ||||||
| 				bubbles: true, | 				bubbles: true, | ||||||
| 				detail: { | 				detail: { | ||||||
| 					id: id, | 					id: id, | ||||||
| 					value: this.collection[id], | 					value: this.collection[id], | ||||||
| 					name: name, | 					name: name, | ||||||
| 				}, | 				}, | ||||||
| 		})); | 			}) | ||||||
|  | 		); | ||||||
| 		this.is_renaming = false; | 		this.is_renaming = false; | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	on_tombstone(event) { | 	on_tombstone(event) { | ||||||
| 		let id = this.shadowRoot.getElementById('select').value; | 		let id = this.shadowRoot.getElementById('select').value; | ||||||
| 		if (confirm(`Are you sure you want to delete '${this.collection[id].name}'?`)) { | 		if ( | ||||||
| 			this.dispatchEvent(new CustomEvent('tombstone', { | 			confirm(`Are you sure you want to delete '${this.collection[id].name}'?`) | ||||||
|  | 		) { | ||||||
|  | 			this.dispatchEvent( | ||||||
|  | 				new CustomEvent('tombstone', { | ||||||
| 					bubbles: true, | 					bubbles: true, | ||||||
| 					detail: { | 					detail: { | ||||||
| 						id: id, | 						id: id, | ||||||
| 						value: this.collection[id], | 						value: this.collection[id], | ||||||
| 					}, | 					}, | ||||||
| 			})); | 				}) | ||||||
|  | 			); | ||||||
| 		} | 		} | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	on_selected(event) { | 	on_selected(event) { | ||||||
| 		let id = event.srcElement.value; | 		let id = event.srcElement.value; | ||||||
| 		this.selected_id = id != '' ? id : undefined; | 		this.selected_id = id != '' ? id : undefined; | ||||||
| 		this.dispatchEvent(new CustomEvent('change', { | 		this.dispatchEvent( | ||||||
|  | 			new CustomEvent('change', { | ||||||
| 				bubbles: true, | 				bubbles: true, | ||||||
| 				detail: { | 				detail: { | ||||||
| 					id: id, | 					id: id, | ||||||
| 					value: this.collection[id], | 					value: this.collection[id], | ||||||
| 				}, | 				}, | ||||||
| 		})); | 			}) | ||||||
|  | 		); | ||||||
| 	} | 	} | ||||||
|  |  | ||||||
| 	render() { | 	render() { | ||||||
| 		let self = this; | 		let self = this; | ||||||
| 		return html` | 		return html` | ||||||
| 			<span style="display: inline-flex; flex-direction: row"> | 			<link rel="stylesheet" href="tildefriends.css"/> | ||||||
|  | 			<span class="inline-flex-row"> | ||||||
| 				<select @change=${this.on_selected} id="select" value=${this.selected_id}> | 				<select @change=${this.on_selected} id="select" value=${this.selected_id}> | ||||||
| 					<option value="" ?selected=${this.selected_id === ''} disabled hidden>(select)</option> | 					<option value="" ?selected=${this.selected_id === ''} disabled hidden>(select ${this.category})</option> | ||||||
| 					${Object.values(this.collection ?? {}).sort((x, y) => x.name.localeCompare(y.name)).map(x => html`<option value=${x.id} ?selected=${this.selected_id === x.id}>${x.name}</option>`)} | 					${Object.values(this.collection ?? {}) | ||||||
|  | 						.sort((x, y) => x.name.localeCompare(y.name)) | ||||||
|  | 						.map( | ||||||
|  | 							(x) => | ||||||
|  | 								html`<option | ||||||
|  | 									value=${x.id} | ||||||
|  | 									?selected=${this.selected_id === x.id} | ||||||
|  | 								> | ||||||
|  | 									${x.name} | ||||||
|  | 								</option>` | ||||||
|  | 						)} | ||||||
| 				</select> | 				</select> | ||||||
| 				<span ?hidden=${!this.is_renaming || !this.whoami}> | 				<span ?hidden=${!this.is_renaming || !this.whoami}> | ||||||
| 					<span style="display: inline-flex; flex-direction: row; margin-left: 8px; margin-right: 8px"> | 					<span class="inline-flex-row" style="margin-left: 8px; margin-right: 8px"> | ||||||
| 						<label for="rename_name">🏷Rename to:</label> | 						<label for="rename_name">🏷Rename to:</label> | ||||||
| 						<input type="text" id="rename_name"></input> | 						<input type="text" id="rename_name"></input> | ||||||
| 						<button @click=${this.on_rename}>Rename ${this.type}</button> | 						<button @click=${this.on_rename}>Rename ${this.type}</button> | ||||||
| 						<button @click=${() => self.is_renaming = false}>x</button> | 						<button @click=${() => (self.is_renaming = false)}>x</button> | ||||||
| 					</span> | 					</span> | ||||||
| 				</span> | 				</span> | ||||||
| 				<button @click=${() => self.is_renaming = true} ?disabled=${this.is_renaming || !this.selected_id} ?hidden=${!this.whoami}>🏷</button> | 				<button class="yellow" @click=${() => (self.is_renaming = true)} ?disabled=${this.is_renaming || !this.selected_id} ?hidden=${!this.whoami}>🏷</button> | ||||||
| 				<button @click=${self.on_tombstone} ?disabled=${!this.selected_id} ?hidden=${!this.whoami}>🪦</button> | 				<button class="red" @click=${self.on_tombstone} ?disabled=${!this.selected_id} ?hidden=${!this.whoami}>🪦</button> | ||||||
| 				<span ?hidden=${!this.is_creating || !this.whoami}> | 				<span ?hidden=${!this.is_creating || !this.whoami}> | ||||||
| 					<label for="create_name">New ${this.type} name:</label> | 					<label for="create_name">New ${this.type} name:</label> | ||||||
| 					<input type="text" id="create_name"></input> | 					<input type="text" id="create_name"></input> | ||||||
| 					<button @click=${this.on_create}>Create ${this.type}</button> | 					<button @click=${this.on_create}>Create ${this.type}</button> | ||||||
| 					<button @click=${() => self.is_creating = false}>x</button> | 					<button @click=${() => (self.is_creating = false)}>x</button> | ||||||
| 				</span> | 				</span> | ||||||
| 				<button @click=${() => self.is_creating = true} ?hidden=${this.is_creating || !this.whoami}>+</button> | 				<button class="green" @click=${() => (self.is_creating = true)} ?hidden=${this.is_creating || !this.whoami}>+</button> | ||||||
| 			</span> | 			</span> | ||||||
| 		`; | 		`; | ||||||
| 	} | 	} | ||||||
|   | |||||||
| @@ -1,36 +0,0 @@ | |||||||
| import {LitElement, html} from './lit-all.min.js'; |  | ||||||
| import * as tfrpc from '/static/tfrpc.js'; |  | ||||||
|  |  | ||||||
| /* |  | ||||||
| ** Provide a list of IDs, and this lets the user pick one. |  | ||||||
| */ |  | ||||||
| class TfIdentityPickerElement extends LitElement { |  | ||||||
| 	static get properties() { |  | ||||||
| 		return { |  | ||||||
| 			ids: {type: Array}, |  | ||||||
| 			selected: {type: String}, |  | ||||||
| 		}; |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	constructor() { |  | ||||||
| 		super(); |  | ||||||
| 		this.ids = []; |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	changed(event) { |  | ||||||
| 		this.selected = event.srcElement.value; |  | ||||||
| 		this.dispatchEvent(new Event('change', { |  | ||||||
| 			srcElement: this, |  | ||||||
| 		})); |  | ||||||
| 	} |  | ||||||
|  |  | ||||||
| 	render() { |  | ||||||
| 		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> |  | ||||||
| 		`; |  | ||||||
| 	} |  | ||||||
| } |  | ||||||
|  |  | ||||||
| customElements.define('tf-id-picker', TfIdentityPickerElement); |  | ||||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user