forked from cory/tildefriends
		
	Compare commits
	
		
			100 Commits
		
	
	
		
			dev_tasia
			...
			5474c5a101
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
| 5474c5a101 | |||
| 4b7261fa20 | |||
| 4992ff3a2d | |||
| 88ee0aa6f0 | |||
| fdda628be8 | |||
| 2b45d8aa05 | |||
| 0e2fc65301 | |||
| 392206c19e | |||
| 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 | |||
| 
						
						
							
						
						f9e95e5733
	
				 | 
					
					
						|||
| 
						
						
							
						
						1444c945de
	
				 | 
					
					
						|||
| e361c3f975 | |||
| 260706c172 | |||
| 390668ec34 | |||
| 1d5cdf9607 | |||
| a4bf3542e0 | |||
| df82cfe66b | |||
| f23414adaf | |||
| 41024ddb79 | |||
| 53f9547cc5 | |||
| 4bfd9de100 | |||
| c01e00d77d | |||
| 825191c08f | |||
| 9dc6670795 | |||
| 1db8eee9f7 | |||
| 1bc50cb62c | |||
| 450b07fd08 | 
							
								
								
									
										2
									
								
								.git-blame-ignore-revs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								.git-blame-ignore-revs
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
# Add prettier to the project
 | 
			
		||||
41024ddb7961b04a5688bbc997cb74de6fab4763
 | 
			
		||||
							
								
								
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										12
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,4 +1,10 @@
 | 
			
		||||
.keys
 | 
			
		||||
out
 | 
			
		||||
**/node_modules
 | 
			
		||||
db.*
 | 
			
		||||
deps/ios_toolchain/
 | 
			
		||||
deps/openssl/
 | 
			
		||||
dist/
 | 
			
		||||
.keys
 | 
			
		||||
**/node_modules
 | 
			
		||||
out
 | 
			
		||||
*.swo
 | 
			
		||||
*.swp
 | 
			
		||||
.zsign_cache/
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										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`.
 | 
			
		||||
							
								
								
									
										136
									
								
								GNUmakefile
									
									
									
									
									
								
							
							
						
						
									
										136
									
								
								GNUmakefile
									
									
									
									
									
								
							@@ -3,9 +3,12 @@
 | 
			
		||||
MAKEFLAGS += --warn-undefined-variables
 | 
			
		||||
MAKEFLAGS += --no-builtin-rules
 | 
			
		||||
 | 
			
		||||
VERSION_CODE := 16
 | 
			
		||||
VERSION_NUMBER := 0.0.16-wip
 | 
			
		||||
VERSION_NAME := Medium English breakfast tea.
 | 
			
		||||
VERSION_CODE := 17
 | 
			
		||||
VERSION_NUMBER := 0.0.17-wip
 | 
			
		||||
VERSION_NAME := Please enjoy responsibly.
 | 
			
		||||
 | 
			
		||||
SQLITE_URL := https://www.sqlite.org/2024/sqlite-amalgamation-3450200.zip
 | 
			
		||||
LIBUV_URL := https://dist.libuv.org/dist/v1.48.0/libuv-v1.48.0.tar.gz
 | 
			
		||||
 | 
			
		||||
PROJECT = tildefriends
 | 
			
		||||
BUILD_DIR ?= out
 | 
			
		||||
@@ -14,6 +17,18 @@ UNAME_M := $(shell uname -m)
 | 
			
		||||
 | 
			
		||||
ANDROID_SDK ?= ~/Android/Sdk
 | 
			
		||||
 | 
			
		||||
ifeq ($(UNAME_M),x86_64)
 | 
			
		||||
ifneq ($(UNAME_S),Haiku)
 | 
			
		||||
debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common
 | 
			
		||||
debug: LDFLAGS += -fsanitize=address -fsanitize=undefined
 | 
			
		||||
endif
 | 
			
		||||
endif
 | 
			
		||||
 | 
			
		||||
ifeq ($(UNAME_M),aarch64)
 | 
			
		||||
debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common
 | 
			
		||||
debug: LDFLAGS += -fsanitize=address -fsanitize=undefined
 | 
			
		||||
endif
 | 
			
		||||
 | 
			
		||||
ifeq ($(UNAME_S),Darwin)
 | 
			
		||||
BUILD_TYPES := macosdebug macosrelease iosdebug iosrelease iossimdebug iossimrelease
 | 
			
		||||
else ifeq ($(UNAME_S),Linux)
 | 
			
		||||
@@ -55,7 +70,7 @@ CFLAGS += \
 | 
			
		||||
 | 
			
		||||
ANDROID_BUILD_TOOLS := $(ANDROID_SDK)/build-tools/34.0.0
 | 
			
		||||
ANDROID_PLATFORM := $(ANDROID_SDK)/platforms/android-34
 | 
			
		||||
ANDROID_NDK ?= $(ANDROID_SDK)/ndk/26.1.10909125
 | 
			
		||||
ANDROID_NDK ?= $(ANDROID_SDK)/ndk/26.2.11394342
 | 
			
		||||
ANDROID_MIN_SDK_VERSION := 24
 | 
			
		||||
ANDROID_TARGET_SDK_VERSION := 34
 | 
			
		||||
 | 
			
		||||
@@ -207,18 +222,6 @@ $(IOS_TARGETS): LDFLAGS += -Ldeps/openssl/ios/ios64-xcrun/usr/local/lib
 | 
			
		||||
$(IOSSIM_TARGETS): CFLAGS += -Ideps/openssl/ios/iossimulator-xcrun/usr/local/include
 | 
			
		||||
$(IOSSIM_TARGETS): LDFLAGS += -Ldeps/openssl/ios/iossimulator-xcrun/usr/local/lib
 | 
			
		||||
 | 
			
		||||
ifeq ($(UNAME_M),x86_64)
 | 
			
		||||
ifneq ($(UNAME_S),Haiku)
 | 
			
		||||
debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common
 | 
			
		||||
debug: LDFLAGS += -fsanitize=address -fsanitize=undefined
 | 
			
		||||
endif
 | 
			
		||||
endif
 | 
			
		||||
 | 
			
		||||
ifeq ($(UNAME_M),aarch64)
 | 
			
		||||
debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common
 | 
			
		||||
debug: LDFLAGS += -fsanitize=address -fsanitize=undefined
 | 
			
		||||
endif
 | 
			
		||||
 | 
			
		||||
get_objs = \
 | 
			
		||||
	$(foreach build_type,$(BUILD_TYPES),$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)))))) \
 | 
			
		||||
	$(foreach build_type,debug release,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix))))) \
 | 
			
		||||
@@ -245,7 +248,6 @@ $(APP_OBJS): CFLAGS += \
 | 
			
		||||
	-Ideps/quickjs \
 | 
			
		||||
	-Ideps/sqlite \
 | 
			
		||||
	-Ideps/valgrind \
 | 
			
		||||
	-Ideps/xopt \
 | 
			
		||||
	-Wdouble-promotion \
 | 
			
		||||
	-Werror
 | 
			
		||||
ifeq ($(UNAME_M),x86_64)
 | 
			
		||||
@@ -497,18 +499,6 @@ $(SQLITE_OBJS): CFLAGS += \
 | 
			
		||||
	-Wno-unused-function \
 | 
			
		||||
	-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 := \
 | 
			
		||||
	deps/quickjs/cutils.c \
 | 
			
		||||
	deps/quickjs/libbf.c \
 | 
			
		||||
@@ -637,8 +627,7 @@ ALL_APP_OBJS := \
 | 
			
		||||
	$(QUICKJS_OBJS) \
 | 
			
		||||
	$(SODIUM_OBJS) \
 | 
			
		||||
	$(SQLITE_OBJS) \
 | 
			
		||||
	$(UV_OBJS) \
 | 
			
		||||
	$(XOPT_OBJS)
 | 
			
		||||
	$(UV_OBJS)
 | 
			
		||||
 | 
			
		||||
DEPS = $(ALL_APP_OBJS:.o=.d)
 | 
			
		||||
-include $(DEPS)
 | 
			
		||||
@@ -717,7 +706,7 @@ PACKAGE_DIRS := \
 | 
			
		||||
	deps/prettier/ \
 | 
			
		||||
	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-release.unsigned.apk: BUILD_TYPE := release
 | 
			
		||||
@@ -736,10 +725,11 @@ out/apk/TildeFriends-arm-%.unsigned.apk:
 | 
			
		||||
	@cp out/android$(BUILD_TYPE)-armv7a/tildefriends out/apk-arm-$(BUILD_TYPE)/lib/armeabi-v7a/tildefriends.so
 | 
			
		||||
	@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/tildefriends.so
 | 
			
		||||
	@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-arm-$(BUILD_TYPE)/lib/armeabi-v7a/tildefriends.so
 | 
			
		||||
	@cp out/apk/res.apk $@
 | 
			
		||||
	@cp out/apk/res.apk $@.zip
 | 
			
		||||
	@cp out/apk/classes.dex out/apk-arm-$(BUILD_TYPE)/
 | 
			
		||||
	@cd out/apk-arm-$(BUILD_TYPE) && zip -u ../../$@ -q -9 -r . && cd ../../
 | 
			
		||||
	@zip -u $@ -q -9 $(RAW_FILES)
 | 
			
		||||
	@cd out/apk-arm-$(BUILD_TYPE) && zip -u ../../$@.zip -q -9 -r . && cd ../../
 | 
			
		||||
	@zip -u $@.zip -q $(RAW_FILES)
 | 
			
		||||
	@$(ANDROID_BUILD_TOOLS)/zipalign -f 4 $@.zip $@
 | 
			
		||||
 | 
			
		||||
out/apk/TildeFriends-x86-%.unsigned.apk:
 | 
			
		||||
	@mkdir -p $(dir $@) out/apk-x86-$(BUILD_TYPE)/lib/x86_64/ out/apk-x86-$(BUILD_TYPE)/lib/x86/
 | 
			
		||||
@@ -748,16 +738,22 @@ out/apk/TildeFriends-x86-%.unsigned.apk:
 | 
			
		||||
	@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/tildefriends.so
 | 
			
		||||
	@cp out/apk/res.apk $@
 | 
			
		||||
	@cp out/apk/res.apk $@.zip
 | 
			
		||||
	@cp out/apk/classes.dex out/apk-x86-$(BUILD_TYPE)/
 | 
			
		||||
	@cd out/apk-x86-$(BUILD_TYPE) && zip -u ../../$@ -q -9 -r . && cd ../../
 | 
			
		||||
	@zip -u $@ -q -9 $(RAW_FILES)
 | 
			
		||||
	@cd out/apk-x86-$(BUILD_TYPE) && zip -u ../../$@.zip -q -9 -r . && cd ../../
 | 
			
		||||
	@zip -u $@.zip -q $(RAW_FILES)
 | 
			
		||||
	@$(ANDROID_BUILD_TOOLS)/zipalign -f 4 $@.zip $@
 | 
			
		||||
 | 
			
		||||
out/%.apk: out/apk/%.unsigned.apk
 | 
			
		||||
	@echo "[apksigner] $(notdir $@)"
 | 
			
		||||
	@$(ANDROID_BUILD_TOOLS)/apksigner sign --ks .keys/android.jks --ks-key-alias androidKey --ks-pass pass:android --key-pass pass:android --out $@ $<
 | 
			
		||||
	@$(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
 | 
			
		||||
 | 
			
		||||
releaseapkgo: out/TildeFriends-arm-release.apk
 | 
			
		||||
@@ -814,30 +810,49 @@ apklog:
 | 
			
		||||
 | 
			
		||||
fetchdeps:
 | 
			
		||||
	@echo "[fetch] libuv"
 | 
			
		||||
	@test -f out/deps/libuv.tar.gz || (mkdir -p out/deps/ && curl -q https://dist.libuv.org/dist/v1.48.0/libuv-v1.48.0.tar.gz -o out/deps/libuv.tar.gz)
 | 
			
		||||
	@test -d deps/libuv/ || (mkdir -p deps/libuv/ && tar -C deps/libuv/ -m --strip=1 -xf out/deps/libuv.tar.gz)
 | 
			
		||||
	@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 || (mkdir -p out/deps/ && curl -q https://www.sqlite.org/2024/sqlite-amalgamation-3450100.zip -o out/deps/sqlite.zip)
 | 
			
		||||
	@test -d deps/sqlite/ || (mkdir -p deps/sqlite/ && unzip -qDj -d deps/sqlite/ out/deps/sqlite.zip)
 | 
			
		||||
	@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
 | 
			
		||||
.PHONE: fetchdeps
 | 
			
		||||
.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:
 | 
			
		||||
	rm -rf $(BUILD_DIR)
 | 
			
		||||
.PHONY: clean
 | 
			
		||||
 | 
			
		||||
dist: release-apk iosrelease-ipa
 | 
			
		||||
	@echo "[export] $$(svn info --show-item url)"
 | 
			
		||||
	@rm -rf tildefriends-$(VERSION_NUMBER)
 | 
			
		||||
	@svn export -q . tildefriends-$(VERSION_NUMBER)
 | 
			
		||||
	@echo "tildefriends-$(VERSION_NUMBER): $(VERSION_NAME)" > tildefriends-$(VERSION_NUMBER)/VERSION
 | 
			
		||||
	@echo "[tar] tildefriends-$(VERSION_NUMBER).tar.xz"
 | 
			
		||||
	@echo [archive] dist/tildefriends-$(VERSION_NUMBER).tar.xz
 | 
			
		||||
	@rm -rf out/tildefriends-$(VERSION_NUMBER)
 | 
			
		||||
	@mkdir -p dist/ out/tildefriends-$(VERSION_NUMBER)
 | 
			
		||||
	@git archive main | tar -x -C out/tildefriends-$(VERSION_NUMBER)
 | 
			
		||||
	@tar \
 | 
			
		||||
		--exclude=apps/gg* \
 | 
			
		||||
		--exclude=apps/welcome* \
 | 
			
		||||
		--exclude=deps/libbacktrace/Isaac.Newton-Opticks.txt \
 | 
			
		||||
		--exclude=deps/libsodium/builds/msvc/vs* \
 | 
			
		||||
@@ -852,14 +867,15 @@ dist: release-apk iosrelease-ipa
 | 
			
		||||
		--exclude=deps/sqlite/shell.c \
 | 
			
		||||
		--exclude=deps/zlib/contrib/vstudio \
 | 
			
		||||
		--exclude=deps/zlib/doc \
 | 
			
		||||
		-caf tildefriends-$(VERSION_NUMBER).tar.xz tildefriends-$(VERSION_NUMBER)
 | 
			
		||||
	@rm -rf tildefriends-$(VERSION_NUMBER)
 | 
			
		||||
		-caf dist/tildefriends-$(VERSION_NUMBER).tar.xz \
 | 
			
		||||
		-C out/ \
 | 
			
		||||
		tildefriends-$(VERSION_NUMBER)
 | 
			
		||||
	@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"
 | 
			
		||||
	@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"
 | 
			
		||||
	@cp out/tildefriends-release.ipa TildeFriends-$(VERSION_NUMBER).ipa
 | 
			
		||||
	@cp out/tildefriends-release.ipa dist/TildeFriends-$(VERSION_NUMBER).ipa
 | 
			
		||||
.PHONY: dist
 | 
			
		||||
 | 
			
		||||
dist-test: dist
 | 
			
		||||
@@ -872,3 +888,11 @@ dist-test: dist
 | 
			
		||||
format:
 | 
			
		||||
	@clang-format -i $(wildcard src/*.c src/*.h src/*.m)
 | 
			
		||||
.PHONY: format
 | 
			
		||||
 | 
			
		||||
prettier:
 | 
			
		||||
	@npm run prettier
 | 
			
		||||
.PHONY: prettier
 | 
			
		||||
 | 
			
		||||
docs:
 | 
			
		||||
	@doxygen
 | 
			
		||||
.PHONY: docs
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										20
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										20
									
								
								README.md
									
									
									
									
									
								
							@@ -1,4 +1,5 @@
 | 
			
		||||
# Tilde Friends
 | 
			
		||||
 | 
			
		||||
Tilde Friends is a tool for making and sharing.
 | 
			
		||||
 | 
			
		||||
A public instance lives at https://www.tildefriends.net/.
 | 
			
		||||
@@ -7,37 +8,42 @@ 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.
 | 
			
		||||
 | 
			
		||||
## Goals
 | 
			
		||||
 | 
			
		||||
1. Make it easy and fun to run all sorts of web applications.
 | 
			
		||||
2. Provide security that is easy to understand and protects your data.
 | 
			
		||||
3. Make creating and sharing web applications accessible to anyone with a
 | 
			
		||||
   browser.
 | 
			
		||||
 | 
			
		||||
## Building
 | 
			
		||||
Builds on Linux (x86_64 and aarch64), MacOS, OpenBSD, and Haiku.  Builds for
 | 
			
		||||
 | 
			
		||||
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.
 | 
			
		||||
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
 | 
			
		||||
   generated in a subdirectory of `out/`.
 | 
			
		||||
3. It's possible to build for Android, iOS, and Windows on Linux, if you have
 | 
			
		||||
   the right dependencies in the right places.  `make windebug winrelease
 | 
			
		||||
   iosdebug-ipa iosrelease-ipa release-apk`.
 | 
			
		||||
   the right dependencies in the right places. `make windebug winrelease
 | 
			
		||||
iosdebug-ipa iosrelease-ipa release-apk`.
 | 
			
		||||
4. To build in docker, `docker build .`.
 | 
			
		||||
5. `make format` will normalize formatting to the coding standard.
 | 
			
		||||
 | 
			
		||||
## Running
 | 
			
		||||
 | 
			
		||||
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
 | 
			
		||||
privileges.  Further administration can be done at
 | 
			
		||||
privileges. Further administration can be done at
 | 
			
		||||
<http://localhost:12345/~core/admin/>.
 | 
			
		||||
 | 
			
		||||
## Documentation
 | 
			
		||||
 | 
			
		||||
Docs are a work in progress:
 | 
			
		||||
<https://www.tildefriends.net/~cory/wiki/#test-wiki/tf-app-quick-reference>.
 | 
			
		||||
 | 
			
		||||
## License
 | 
			
		||||
 | 
			
		||||
All code unless otherwise noted in is provided under the
 | 
			
		||||
[MIT](https://opensource.org/licenses/MIT) license.
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "🎛"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🎛"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -18,9 +18,13 @@ async function main() {
 | 
			
		||||
		for (let user of await core.users()) {
 | 
			
		||||
			data.users[user] = await core.permissionsForUser(user);
 | 
			
		||||
		}
 | 
			
		||||
		await app.setDocument(utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data)));
 | 
			
		||||
		await app.setDocument(
 | 
			
		||||
			utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data))
 | 
			
		||||
		);
 | 
			
		||||
	} catch {
 | 
			
		||||
		await app.setDocument('<span style="color: #f00">Only an administrator can modify these settings.</span>');
 | 
			
		||||
		await app.setDocument(
 | 
			
		||||
			'<span style="color: #f00">Only an administrator can modify these settings.</span>'
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
main();
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,10 +1,12 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html style="width: 100%">
 | 
			
		||||
	<head>
 | 
			
		||||
		<script>const g_data = $data;</script>
 | 
			
		||||
		<script>
 | 
			
		||||
			const g_data = $data;
 | 
			
		||||
		</script>
 | 
			
		||||
	</head>
 | 
			
		||||
	<body style="color: #fff; width: 100%">
 | 
			
		||||
		<h1>Tilde Friends Administration</h1>
 | 
			
		||||
	</body>
 | 
			
		||||
	<script type="module" src="script.js"></script>
 | 
			
		||||
</html>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -3,25 +3,32 @@ import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
function delete_user(user) {
 | 
			
		||||
	if (confirm(`Are you sure you want to delete the user "${user}"?`)) {
 | 
			
		||||
		tfrpc.rpc.delete_user(user).then(function() {
 | 
			
		||||
			alert(`User "${user}" deleted successfully.`);
 | 
			
		||||
		}).catch(function(error) {
 | 
			
		||||
			alert(`Failed to delete user "${user}": ${JSON.stringify(error, null, 2)}.`);
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.rpc
 | 
			
		||||
			.delete_user(user)
 | 
			
		||||
			.then(function () {
 | 
			
		||||
				alert(`User "${user}" deleted successfully.`);
 | 
			
		||||
			})
 | 
			
		||||
			.catch(function (error) {
 | 
			
		||||
				alert(
 | 
			
		||||
					`Failed to delete user "${user}": ${JSON.stringify(error, null, 2)}.`
 | 
			
		||||
				);
 | 
			
		||||
			});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function global_settings_set(key, value) {
 | 
			
		||||
	tfrpc.rpc.global_settings_set(key, value).then(function() {
 | 
			
		||||
		alert(`Set "${key}" to "${value}".`);
 | 
			
		||||
	}).catch(function(error) {
 | 
			
		||||
		alert(`Failed to set "${key}": ${JSON.stringify(error, null, 2)}.`);
 | 
			
		||||
	});
 | 
			
		||||
	tfrpc.rpc
 | 
			
		||||
		.global_settings_set(key, value)
 | 
			
		||||
		.then(function () {
 | 
			
		||||
			alert(`Set "${key}" to "${value}".`);
 | 
			
		||||
		})
 | 
			
		||||
		.catch(function (error) {
 | 
			
		||||
			alert(`Failed to set "${key}": ${JSON.stringify(error, null, 2)}.`);
 | 
			
		||||
		});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
window.addEventListener('load', function() {
 | 
			
		||||
	const permission_template = (permission) =>
 | 
			
		||||
		html` <code>${permission}</code>`;
 | 
			
		||||
window.addEventListener('load', function () {
 | 
			
		||||
	const permission_template = (permission) => html` <code>${permission}</code>`;
 | 
			
		||||
	function input_template(key, description) {
 | 
			
		||||
		if (description.type === 'boolean') {
 | 
			
		||||
			return html`
 | 
			
		||||
@@ -62,26 +69,24 @@ window.addEventListener('load', function() {
 | 
			
		||||
	}
 | 
			
		||||
	const user_template = (user, permissions) => html`
 | 
			
		||||
		<li>
 | 
			
		||||
			<button @click=${(e) => delete_user(user)}>
 | 
			
		||||
				Delete
 | 
			
		||||
			</button>
 | 
			
		||||
			${user}:
 | 
			
		||||
			${permissions.map(x => permission_template(x))}
 | 
			
		||||
			<button @click=${(e) => delete_user(user)}>Delete</button>
 | 
			
		||||
			${user}: ${permissions.map((x) => permission_template(x))}
 | 
			
		||||
		</li>
 | 
			
		||||
	`;
 | 
			
		||||
	const users_template = (users) =>
 | 
			
		||||
		html`<h2>Users</h2>
 | 
			
		||||
		<ul>
 | 
			
		||||
			${Object.entries(users).map(u => user_template(u[0], u[1]))}
 | 
			
		||||
		</ul>`;
 | 
			
		||||
			<ul>
 | 
			
		||||
				${Object.entries(users).map((u) => user_template(u[0], u[1]))}
 | 
			
		||||
			</ul>`;
 | 
			
		||||
	const page_template = (data) =>
 | 
			
		||||
		html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%">
 | 
			
		||||
			<h2>Global Settings</h2>
 | 
			
		||||
			<div>
 | 
			
		||||
			${Object.keys(data.settings).sort().map(x => html`${input_template(x, data.settings[x])}`)}
 | 
			
		||||
				${Object.keys(data.settings)
 | 
			
		||||
					.sort()
 | 
			
		||||
					.map((x) => html`${input_template(x, data.settings[x])}`)}
 | 
			
		||||
			</div>
 | 
			
		||||
			${users_template(data.users)}
 | 
			
		||||
		</div>
 | 
			
		||||
		`;
 | 
			
		||||
		</div> `;
 | 
			
		||||
	render(page_template(g_data), document.body);
 | 
			
		||||
});
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "📜",
 | 
			
		||||
  "previous": "&miGORZ8BwjHg2YO0t4bms6SI28XWPYqnqOZ8u9zsbZc=.sha256"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "📜",
 | 
			
		||||
	"previous": "&miGORZ8BwjHg2YO0t4bms6SI28XWPYqnqOZ8u9zsbZc=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -219,7 +219,7 @@ Parses an HTTP response.
 | 
			
		||||
 * *Object* An object with **bytes_parsed**, **minor_version**, **status**, **message**, and **headers** fields on successful parse.
 | 
			
		||||
`;
 | 
			
		||||
 | 
			
		||||
docs['sha1Digest()'] =`
 | 
			
		||||
docs['sha1Digest()'] = `
 | 
			
		||||
Calculates a SHA1 digest.
 | 
			
		||||
 | 
			
		||||
Completes synchronously.
 | 
			
		||||
@@ -353,4 +353,4 @@ Call a remote function.
 | 
			
		||||
 * **...** Parameters to pass to the function.
 | 
			
		||||
### Returns
 | 
			
		||||
The return value of the called function.
 | 
			
		||||
`;
 | 
			
		||||
`;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "💻",
 | 
			
		||||
  "previous": "&RdVEsVscZm3aWzcMrEZS8mskO5tUmvaEUihex2MMfZQ=.sha256"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "💻",
 | 
			
		||||
	"previous": "&icsPplXHgmpkbNWyo/WTvRdT/A8BXxW4lJixOtP4ueQ=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -26,14 +26,15 @@ async function fetch_info(apps) {
 | 
			
		||||
async function fetch_shared_apps() {
 | 
			
		||||
	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"')
 | 
			
		||||
				JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
				ORDER BY timestamp
 | 
			
		||||
				ORDER BY messages.timestamp
 | 
			
		||||
		`,
 | 
			
		||||
		[],
 | 
			
		||||
		function(row) {
 | 
			
		||||
		function (row) {
 | 
			
		||||
			let content = JSON.parse(row.content);
 | 
			
		||||
			for (let mention of content.mentions) {
 | 
			
		||||
				if (mention?.type === 'application/tildefriends') {
 | 
			
		||||
@@ -44,10 +45,13 @@ async function fetch_shared_apps() {
 | 
			
		||||
					};
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
		}
 | 
			
		||||
	);
 | 
			
		||||
 | 
			
		||||
	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)));
 | 
			
		||||
		if (app_object) {
 | 
			
		||||
			app_object.blob_id = app.blob;
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "🪵",
 | 
			
		||||
  "previous": "&TIrBnpN3iz3O9L9MCCteAcVJZjA83EKdcfu4SCM76VE=.sha256"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🪵",
 | 
			
		||||
	"previous": "&TIrBnpN3iz3O9L9MCCteAcVJZjA83EKdcfu4SCM76VE=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -5,4 +5,4 @@ async function main() {
 | 
			
		||||
	await app.setDocument(blog.render_html(blogs));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,11 +1,19 @@
 | 
			
		||||
import * as commonmark from './commonmark.min.js';
 | 
			
		||||
 | 
			
		||||
function escape(text) {
 | 
			
		||||
	return (text ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
 | 
			
		||||
	return (text ?? '')
 | 
			
		||||
		.replaceAll('&', '&')
 | 
			
		||||
		.replaceAll('<', '<')
 | 
			
		||||
		.replaceAll('>', '>');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function escapeAttribute(text) {
 | 
			
		||||
	return (text ?? '').replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>').replaceAll('"', '"').replaceAll("'", ''');
 | 
			
		||||
	return (text ?? '')
 | 
			
		||||
		.replaceAll('&', '&')
 | 
			
		||||
		.replaceAll('<', '<')
 | 
			
		||||
		.replaceAll('>', '>')
 | 
			
		||||
		.replaceAll('"', '"')
 | 
			
		||||
		.replaceAll("'", ''');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function get_blog_message(id) {
 | 
			
		||||
@@ -13,7 +21,7 @@ export async function get_blog_message(id) {
 | 
			
		||||
	await ssb.sqlAsync(
 | 
			
		||||
		'SELECT author, timestamp, content FROM messages WHERE id = ?',
 | 
			
		||||
		[id],
 | 
			
		||||
		function(row) {
 | 
			
		||||
		function (row) {
 | 
			
		||||
			let content = JSON.parse(row.content);
 | 
			
		||||
			message = {
 | 
			
		||||
				author: row.author,
 | 
			
		||||
@@ -21,7 +29,8 @@ export async function get_blog_message(id) {
 | 
			
		||||
				blog: content?.blog,
 | 
			
		||||
				title: content?.title,
 | 
			
		||||
			};
 | 
			
		||||
		});
 | 
			
		||||
		}
 | 
			
		||||
	);
 | 
			
		||||
	if (message) {
 | 
			
		||||
		await ssb.sqlAsync(
 | 
			
		||||
			`
 | 
			
		||||
@@ -34,9 +43,10 @@ export async function get_blog_message(id) {
 | 
			
		||||
				ORDER BY sequence DESC LIMIT 1
 | 
			
		||||
			`,
 | 
			
		||||
			[message.author],
 | 
			
		||||
			function(row) {
 | 
			
		||||
			function (row) {
 | 
			
		||||
				message.name = row.name;
 | 
			
		||||
			});
 | 
			
		||||
			}
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
	return message;
 | 
			
		||||
}
 | 
			
		||||
@@ -51,8 +61,12 @@ export function markdown(md) {
 | 
			
		||||
		node = event.node;
 | 
			
		||||
		if (event.entering) {
 | 
			
		||||
			if (node.destination?.startsWith('&')) {
 | 
			
		||||
				node.destination = '/' + node.destination + '/view?filename=' + node.firstChild?.literal;
 | 
			
		||||
			} else if (node.destination?.startsWith('@') || node.destination?.startsWith('%')) {
 | 
			
		||||
				node.destination =
 | 
			
		||||
					'/' + node.destination + '/view?filename=' + node.firstChild?.literal;
 | 
			
		||||
			} else if (
 | 
			
		||||
				node.destination?.startsWith('@') ||
 | 
			
		||||
				node.destination?.startsWith('%')
 | 
			
		||||
			) {
 | 
			
		||||
				node.destination = '/~core/ssb/#' + escape(node.destination);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
@@ -107,7 +121,7 @@ export function render_html(blogs) {
 | 
			
		||||
					<h1>🪵Tilde Friends Blog</h1>
 | 
			
		||||
					<div style="font-size: xx-small; vertical-align: middle"><a href="/~cory/blog/atom">atom feed</a></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>
 | 
			
		||||
		</html>`;
 | 
			
		||||
}
 | 
			
		||||
@@ -135,14 +149,15 @@ export function render_atom(blogs) {
 | 
			
		||||
	<link href="${core.url}"/>
 | 
			
		||||
	<id>${core.url}</id>
 | 
			
		||||
	<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>`;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export async function get_posts() {
 | 
			
		||||
	let blogs = [];
 | 
			
		||||
	let ids = await ssb.getIdentities();
 | 
			
		||||
	await ssb.sqlAsync(`
 | 
			
		||||
	await ssb.sqlAsync(
 | 
			
		||||
		`
 | 
			
		||||
		WITH
 | 
			
		||||
			blogs AS (
 | 
			
		||||
				SELECT
 | 
			
		||||
@@ -182,8 +197,11 @@ export async function get_posts() {
 | 
			
		||||
		JOIN public ON public.author = blogs.author
 | 
			
		||||
		LEFT OUTER JOIN names ON names.author = blogs.author
 | 
			
		||||
		ORDER BY blogs.timestamp DESC LIMIT 20
 | 
			
		||||
	`, [JSON.stringify(ids)], function(row) {
 | 
			
		||||
		blogs.push(row);
 | 
			
		||||
	});
 | 
			
		||||
	`,
 | 
			
		||||
		[JSON.stringify(ids)],
 | 
			
		||||
		function (row) {
 | 
			
		||||
			blogs.push(row);
 | 
			
		||||
		}
 | 
			
		||||
	);
 | 
			
		||||
	return blogs;
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,30 +2,50 @@ import * as blog from './blog.js';
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	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);
 | 
			
		||||
		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 {
 | 
			
		||||
			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') {
 | 
			
		||||
		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 {
 | 
			
		||||
		let blogs = await blog.get_posts();
 | 
			
		||||
		for (let blog_post of blogs) {
 | 
			
		||||
			let title = (blog_post.title || '').replaceAll(/\W/g, '_').toLowerCase();
 | 
			
		||||
			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;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		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) {
 | 
			
		||||
	respond({data: `<!DOCTYPE html>
 | 
			
		||||
	<pre style="color: #f00">${error.message}\n${error.stack}</pre>`, content_type: 'text/html'});
 | 
			
		||||
});
 | 
			
		||||
main().catch(function (error) {
 | 
			
		||||
	respond({
 | 
			
		||||
		data: `<!DOCTYPE html>
 | 
			
		||||
	<pre style="color: #f00">${error.message}\n${error.stack}</pre>`,
 | 
			
		||||
		content_type: 'text/html',
 | 
			
		||||
	});
 | 
			
		||||
});
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "💽"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "💽"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -51,7 +51,7 @@ async function key_list(db) {
 | 
			
		||||
	app.setDocument(doc);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
core.register('message', async function(message) {
 | 
			
		||||
core.register('message', async function (message) {
 | 
			
		||||
	if (message.event == 'hashChange') {
 | 
			
		||||
		let hash = message.hash.substring(1);
 | 
			
		||||
		if (hash.startsWith(':shared:')) {
 | 
			
		||||
@@ -67,4 +67,4 @@ core.register('message', async function(message) {
 | 
			
		||||
	}
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
database_list();
 | 
			
		||||
database_list();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "➡️"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "➡️"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,7 +2,7 @@ let g_about_cache = {};
 | 
			
		||||
 | 
			
		||||
async function query(sql, args) {
 | 
			
		||||
	let result = [];
 | 
			
		||||
	await ssb.sqlAsync(sql, args, function(row) {
 | 
			
		||||
	await ssb.sqlAsync(sql, args, function (row) {
 | 
			
		||||
		result.push(row);
 | 
			
		||||
	});
 | 
			
		||||
	return result;
 | 
			
		||||
@@ -21,7 +21,8 @@ async function contacts_internal(id, last_row_id, following, max_row_id) {
 | 
			
		||||
				json_extract(content, '$.type') = 'contact'
 | 
			
		||||
				ORDER BY sequence
 | 
			
		||||
			`,
 | 
			
		||||
		[id, last_row_id, max_row_id]);
 | 
			
		||||
		[id, last_row_id, max_row_id]
 | 
			
		||||
	);
 | 
			
		||||
	for (let row of contacts) {
 | 
			
		||||
		let contact = JSON.parse(row.content);
 | 
			
		||||
		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);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function following_deep_internal(ids, depth, blocking, last_row_id, following, max_row_id) {
 | 
			
		||||
	let contacts = await Promise.all([...new Set(ids)].map(x => contact(x, last_row_id, following, max_row_id)));
 | 
			
		||||
async function following_deep_internal(
 | 
			
		||||
	ids,
 | 
			
		||||
	depth,
 | 
			
		||||
	blocking,
 | 
			
		||||
	last_row_id,
 | 
			
		||||
	following,
 | 
			
		||||
	max_row_id
 | 
			
		||||
) {
 | 
			
		||||
	let contacts = await Promise.all(
 | 
			
		||||
		[...new Set(ids)].map((x) => contact(x, last_row_id, following, max_row_id))
 | 
			
		||||
	);
 | 
			
		||||
	let result = {};
 | 
			
		||||
	for (let i = 0; i < ids.length; i++) {
 | 
			
		||||
		let id = ids[i];
 | 
			
		||||
		let contact = contacts[i];
 | 
			
		||||
		let all_blocking = Object.assign({}, contact.blocking, blocking);
 | 
			
		||||
		let found = Object.keys(contact.following).filter(y => !all_blocking[y]);
 | 
			
		||||
		let deeper = depth > 1 ? await following_deep_internal(found, depth - 1, all_blocking, last_row_id, following, max_row_id) : [];
 | 
			
		||||
		let found = Object.keys(contact.following).filter((y) => !all_blocking[y]);
 | 
			
		||||
		let deeper =
 | 
			
		||||
			depth > 1
 | 
			
		||||
				? await following_deep_internal(
 | 
			
		||||
						found,
 | 
			
		||||
						depth - 1,
 | 
			
		||||
						all_blocking,
 | 
			
		||||
						last_row_id,
 | 
			
		||||
						following,
 | 
			
		||||
						max_row_id
 | 
			
		||||
					)
 | 
			
		||||
				: [];
 | 
			
		||||
		result[id] = [id, ...found, ...deeper];
 | 
			
		||||
	}
 | 
			
		||||
	return [...new Set(Object.values(result).flat())];
 | 
			
		||||
@@ -68,10 +88,22 @@ async function following_deep(ids, depth, blocking) {
 | 
			
		||||
			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
 | 
			
		||||
		`, []))[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;
 | 
			
		||||
	let store = JSON.stringify(cache);
 | 
			
		||||
	await db.set('following', store);
 | 
			
		||||
@@ -90,13 +122,15 @@ async function fetch_about(db, ids, users) {
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
	let max_row_id = 0;
 | 
			
		||||
	await ssb.sqlAsync(`
 | 
			
		||||
	await ssb.sqlAsync(
 | 
			
		||||
		`
 | 
			
		||||
			SELECT MAX(rowid) AS max_row_id FROM messages
 | 
			
		||||
		`,
 | 
			
		||||
		[],
 | 
			
		||||
		function(row) {
 | 
			
		||||
		function (row) {
 | 
			
		||||
			max_row_id = row.max_row_id;
 | 
			
		||||
		});
 | 
			
		||||
		}
 | 
			
		||||
	);
 | 
			
		||||
	for (let id of Object.keys(cache.about)) {
 | 
			
		||||
		if (ids.indexOf(id) == -1) {
 | 
			
		||||
			delete cache.about[id];
 | 
			
		||||
@@ -129,17 +163,21 @@ async function fetch_about(db, ids, users) {
 | 
			
		||||
				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,
 | 
			
		||||
			max_row_id,
 | 
			
		||||
		]);
 | 
			
		||||
		]
 | 
			
		||||
	);
 | 
			
		||||
	for (let about of abouts) {
 | 
			
		||||
		let content = JSON.parse(about.content);
 | 
			
		||||
		if (content.about === about.author) {
 | 
			
		||||
			delete content.type;
 | 
			
		||||
			delete content.about;
 | 
			
		||||
			cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content);
 | 
			
		||||
			cache.about[about.author] = Object.assign(
 | 
			
		||||
				cache.about[about.author] || {},
 | 
			
		||||
				content
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	cache.last_row_id = max_row_id;
 | 
			
		||||
@@ -155,41 +193,41 @@ async function getAbout(db, id) {
 | 
			
		||||
	if (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;
 | 
			
		||||
	let f = o ? JSON.parse(o) : o;
 | 
			
		||||
	if (!f || f.version != k_version) {
 | 
			
		||||
		f = {about: {}, sequence: 0, version: k_version};
 | 
			
		||||
	}
 | 
			
		||||
	await ssb.sqlAsync(
 | 
			
		||||
		"SELECT "+
 | 
			
		||||
		"  sequence, "+
 | 
			
		||||
		"  content "+
 | 
			
		||||
		"FROM messages "+
 | 
			
		||||
		"WHERE "+
 | 
			
		||||
		"  author = ?1 AND "+
 | 
			
		||||
		"  sequence > ?2 AND "+
 | 
			
		||||
		"  json_extract(content, '$.type') = 'about' AND "+
 | 
			
		||||
		"  json_extract(content, '$.about') = ?1 "+
 | 
			
		||||
		"UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 "+
 | 
			
		||||
		"ORDER BY sequence",
 | 
			
		||||
		'SELECT ' +
 | 
			
		||||
			'  sequence, ' +
 | 
			
		||||
			'  content ' +
 | 
			
		||||
			'FROM messages ' +
 | 
			
		||||
			'WHERE ' +
 | 
			
		||||
			'  author = ?1 AND ' +
 | 
			
		||||
			'  sequence > ?2 AND ' +
 | 
			
		||||
			"  json_extract(content, '$.type') = 'about' AND " +
 | 
			
		||||
			"  json_extract(content, '$.about') = ?1 " +
 | 
			
		||||
			'UNION SELECT MAX(sequence) as sequence, NULL FROM messages WHERE author = ?1 ' +
 | 
			
		||||
			'ORDER BY sequence',
 | 
			
		||||
		[id, f.sequence],
 | 
			
		||||
		function(row) {
 | 
			
		||||
		function (row) {
 | 
			
		||||
			f.sequence = row.sequence;
 | 
			
		||||
			if (row.content) {
 | 
			
		||||
				let about = {};
 | 
			
		||||
				try {
 | 
			
		||||
					about = JSON.parse(row.content);
 | 
			
		||||
				} catch {
 | 
			
		||||
				}
 | 
			
		||||
				} catch {}
 | 
			
		||||
				delete about.about;
 | 
			
		||||
				delete about.type;
 | 
			
		||||
				f.about = Object.assign(f.about, about);
 | 
			
		||||
			}
 | 
			
		||||
		});
 | 
			
		||||
		}
 | 
			
		||||
	);
 | 
			
		||||
	let j = JSON.stringify(f);
 | 
			
		||||
	if (o != j) {
 | 
			
		||||
		await db.set(id + ":about", j);
 | 
			
		||||
		await db.set(id + ':about', j);
 | 
			
		||||
	}
 | 
			
		||||
	g_about_cache[id] = f.about;
 | 
			
		||||
	return f.about;
 | 
			
		||||
@@ -198,15 +236,15 @@ async function getAbout(db, id) {
 | 
			
		||||
async function getSize(db, id) {
 | 
			
		||||
	let size = 0;
 | 
			
		||||
	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],
 | 
			
		||||
		function (row) {
 | 
			
		||||
			size += row.size;
 | 
			
		||||
		});
 | 
			
		||||
		}
 | 
			
		||||
	);
 | 
			
		||||
	return size;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
async function getSizes(ids) {
 | 
			
		||||
	let sizes = {};
 | 
			
		||||
	await ssb.sqlAsync(
 | 
			
		||||
@@ -221,7 +259,8 @@ async function getSizes(ids) {
 | 
			
		||||
		[JSON.stringify(ids)],
 | 
			
		||||
		function (row) {
 | 
			
		||||
			sizes[row.author] = row.size;
 | 
			
		||||
		});
 | 
			
		||||
		}
 | 
			
		||||
	);
 | 
			
		||||
	return sizes;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@@ -241,7 +280,10 @@ function niceSize(bytes) {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
function escape(value) {
 | 
			
		||||
	return value.replaceAll('&', '&').replaceAll('<', '<').replaceAll('>', '>');
 | 
			
		||||
	return value
 | 
			
		||||
		.replaceAll('&', '&')
 | 
			
		||||
		.replaceAll('<', '<')
 | 
			
		||||
		.replaceAll('>', '>');
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
@@ -249,19 +291,27 @@ async function main() {
 | 
			
		||||
	let db = await database('ssb');
 | 
			
		||||
	let whoami = await ssb.getIdentities();
 | 
			
		||||
	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, {});
 | 
			
		||||
	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([
 | 
			
		||||
		fetch_about(db, following, {}),
 | 
			
		||||
		getSizes(following),
 | 
			
		||||
	]);
 | 
			
		||||
	await app.setDocument(`<pre style="color: #fff">Finishing...</pre>`);
 | 
			
		||||
	following.sort((a, b) => ((sizes[b] ?? 0) - (sizes[a] ?? 0)));
 | 
			
		||||
	following.sort((a, b) => (sizes[b] ?? 0) - (sizes[a] ?? 0));
 | 
			
		||||
	for (let id of following) {
 | 
			
		||||
		tree += `<li><a href="/~core/ssb/#${id}">${escape(about[id]?.name ?? id)}</a> ${niceSize(sizes[id] ?? 0)}</li>\n`;
 | 
			
		||||
	}
 | 
			
		||||
	await app.setDocument('<!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
											
										
									
								
							@@ -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",
 | 
			
		||||
  "emoji": "🪪",
 | 
			
		||||
  "previous": "&kgukkyDk1RxgfzgMH6H/0QeDPIuwPZypLuAFax21ljk=.sha256"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🪪",
 | 
			
		||||
	"previous": "&kgukkyDk1RxgfzgMH6H/0QeDPIuwPZypLuAFax21ljk=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -18,7 +18,8 @@ tfrpc.register(async function reload() {
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	let ids = await ssb.getIdentities();
 | 
			
		||||
	await app.setDocument(`<body style="color: #fff">
 | 
			
		||||
	await app.setDocument(
 | 
			
		||||
		`<body style="color: #fff">
 | 
			
		||||
		<script>const handler = {};</script>
 | 
			
		||||
		<script type="module">
 | 
			
		||||
			import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
@@ -74,14 +75,19 @@ async function main() {
 | 
			
		||||
		<h2>Import an SSB Identity from 12 BIP39 English Words</h2>
 | 
			
		||||
		<textarea id="add_id" style="width: 100%" rows="4"></textarea><button id="add" onclick="handler.add_id(event)">Import Identity</button>
 | 
			
		||||
		<h2>Identities</h2>
 | 
			
		||||
		<ul>`+
 | 
			
		||||
		ids.map(id => `<li>
 | 
			
		||||
		<ul>` +
 | 
			
		||||
			ids
 | 
			
		||||
				.map(
 | 
			
		||||
					(id) => `<li>
 | 
			
		||||
			<button onclick="handler.export_id(event)" data-id="${id}">Export Identity</button>
 | 
			
		||||
			<button onclick="handler.delete_id(event)" data-id="${id}">Delete Identity</button>
 | 
			
		||||
			${id}
 | 
			
		||||
		</li>`).join('\n')+
 | 
			
		||||
	`	</ul>
 | 
			
		||||
	</body>`);
 | 
			
		||||
		</li>`
 | 
			
		||||
				)
 | 
			
		||||
				.join('\n') +
 | 
			
		||||
			`	</ul>
 | 
			
		||||
	</body>`
 | 
			
		||||
	);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "🦟",
 | 
			
		||||
  "previous": "&TegdzvFE+im94shygaHkgDYSaSrwY2h0OKUXSRPBQDM=.sha256"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🦟",
 | 
			
		||||
	"previous": "&TegdzvFE+im94shygaHkgDYSaSrwY2h0OKUXSRPBQDM=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -67,7 +67,7 @@ tfrpc.register(function getHash(id, message) {
 | 
			
		||||
tfrpc.register(function setHash(hash) {
 | 
			
		||||
	return app.setHash(hash);
 | 
			
		||||
});
 | 
			
		||||
ssb.addEventListener('message', async function(id) {
 | 
			
		||||
ssb.addEventListener('message', async function (id) {
 | 
			
		||||
	await tfrpc.rpc.notifyNewMessage(id);
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function store_blob(blob) {
 | 
			
		||||
@@ -88,18 +88,18 @@ tfrpc.register(function apps() {
 | 
			
		||||
tfrpc.register(async function try_decrypt(id, content) {
 | 
			
		||||
	return await ssb.privateMessageDecrypt(id, content);
 | 
			
		||||
});
 | 
			
		||||
ssb.addEventListener('broadcasts', async function() {
 | 
			
		||||
ssb.addEventListener('broadcasts', async function () {
 | 
			
		||||
	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts());
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
core.register('onConnectionsChanged', async function() {
 | 
			
		||||
core.register('onConnectionsChanged', async function () {
 | 
			
		||||
	await tfrpc.rpc.set('connections', await ssb.connections());
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	if (typeof(database) !== 'undefined') {
 | 
			
		||||
	if (typeof database !== 'undefined') {
 | 
			
		||||
		g_database = await database('ssb');
 | 
			
		||||
	}
 | 
			
		||||
	await app.setDocument(utf8Decode(await getFile('index.html')));
 | 
			
		||||
}
 | 
			
		||||
main();
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,16 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html style="color: #fff">
 | 
			
		||||
	<head>
 | 
			
		||||
		<title>Tilde Friends</title>
 | 
			
		||||
		<base target="_top">
 | 
			
		||||
		<base target="_top" />
 | 
			
		||||
	</head>
 | 
			
		||||
	<body>
 | 
			
		||||
		<tf-issues-app/>
 | 
			
		||||
		<script>window.litDisableBundleWarning = true;</script>
 | 
			
		||||
		<tf-issues-app />
 | 
			
		||||
		<script>
 | 
			
		||||
			window.litDisableBundleWarning = true;
 | 
			
		||||
		</script>
 | 
			
		||||
		<script src="commonmark.min.js"></script>
 | 
			
		||||
		<script src="commonmark-linkify.js" type="module"></script>
 | 
			
		||||
		<script src="script.js" type="module"></script>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -31,7 +31,12 @@ class TfIdPickerElement extends LitElement {
 | 
			
		||||
		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>`)}
 | 
			
		||||
					${this.ids.map(
 | 
			
		||||
						(id) =>
 | 
			
		||||
							html`<option ?selected=${id == this.selected} value=${id}>
 | 
			
		||||
								${id}
 | 
			
		||||
							</option>`
 | 
			
		||||
					)}
 | 
			
		||||
				</select>
 | 
			
		||||
			`;
 | 
			
		||||
		} else {
 | 
			
		||||
@@ -57,13 +62,15 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	submit() {
 | 
			
		||||
		this.dispatchEvent(new CustomEvent('tf-submit', {
 | 
			
		||||
			bubbles: true,
 | 
			
		||||
			composed: true,
 | 
			
		||||
			detail: {
 | 
			
		||||
				value: this.renderRoot.getElementById('input').value,
 | 
			
		||||
			},
 | 
			
		||||
		}));
 | 
			
		||||
		this.dispatchEvent(
 | 
			
		||||
			new CustomEvent('tf-submit', {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				composed: true,
 | 
			
		||||
				detail: {
 | 
			
		||||
					value: this.renderRoot.getElementById('input').value,
 | 
			
		||||
				},
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
		this.renderRoot.getElementById('input').value = '';
 | 
			
		||||
		this.input();
 | 
			
		||||
	}
 | 
			
		||||
@@ -96,7 +103,8 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	async load() {
 | 
			
		||||
		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
 | 
			
		||||
				messages.id = messages_refs.message
 | 
			
		||||
				WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'),
 | 
			
		||||
@@ -107,7 +115,9 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
			SELECT * FROM issues
 | 
			
		||||
			UNION
 | 
			
		||||
			SELECT * FROM edits ORDER BY timestamp
 | 
			
		||||
		`, [k_project]);
 | 
			
		||||
		`,
 | 
			
		||||
			[k_project]
 | 
			
		||||
		);
 | 
			
		||||
		for (let message of messages) {
 | 
			
		||||
			let content = JSON.parse(message.content);
 | 
			
		||||
			switch (content.type) {
 | 
			
		||||
@@ -123,7 +133,7 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
					break;
 | 
			
		||||
				case 'issue-edit':
 | 
			
		||||
				case 'post':
 | 
			
		||||
					for (let issue of (content.issues || [])) {
 | 
			
		||||
					for (let issue of content.issues || []) {
 | 
			
		||||
						if (issues[issue.link]) {
 | 
			
		||||
							if (issue.open !== undefined) {
 | 
			
		||||
								issues[issue.link].open = issue.open;
 | 
			
		||||
@@ -136,7 +146,9 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
					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) {
 | 
			
		||||
			for (let issue of this.issues) {
 | 
			
		||||
				if (issue.id == this.selected.id) {
 | 
			
		||||
@@ -150,11 +162,20 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
		return html`
 | 
			
		||||
			<tr>
 | 
			
		||||
				<td>${issue.open ? '☐ open' : '☑ closed'}</td>
 | 
			
		||||
				<td style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis">${issue.author}</td>
 | 
			
		||||
				<td style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer" @click=${() => this.selected = issue}>
 | 
			
		||||
				<td
 | 
			
		||||
					style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis"
 | 
			
		||||
				>
 | 
			
		||||
					${issue.author}
 | 
			
		||||
				</td>
 | 
			
		||||
				<td
 | 
			
		||||
					style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer"
 | 
			
		||||
					@click=${() => (this.selected = issue)}
 | 
			
		||||
				>
 | 
			
		||||
					${issue.text.split('\n')?.[0]}
 | 
			
		||||
				</td>
 | 
			
		||||
				<td>${new Date(issue.updated ?? issue.created).toLocaleDateString()}</td>
 | 
			
		||||
				<td>
 | 
			
		||||
					${new Date(issue.updated ?? issue.created).toLocaleDateString()}
 | 
			
		||||
				</td>
 | 
			
		||||
			</tr>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
@@ -170,13 +191,21 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
				<div>${new Date(update.timestamp).toLocaleString()}</div>
 | 
			
		||||
				<div>${update.author}</div>
 | 
			
		||||
				<div>${message}</div>
 | 
			
		||||
				<div>${update.open !== undefined ? (update.open ? 'issue opened' : 'issue closed') : undefined}</div>
 | 
			
		||||
				<div>
 | 
			
		||||
					${update.open !== undefined
 | 
			
		||||
						? update.open
 | 
			
		||||
							? 'issue opened'
 | 
			
		||||
							: 'issue closed'
 | 
			
		||||
						: undefined}
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async set_open(id, open) {
 | 
			
		||||
		if (confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`)) {
 | 
			
		||||
		if (
 | 
			
		||||
			confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`)
 | 
			
		||||
		) {
 | 
			
		||||
			let whoami = this.shadowRoot.getElementById('picker').selected;
 | 
			
		||||
			await tfrpc.rpc.appendMessage(whoami, {
 | 
			
		||||
				type: 'issue-edit',
 | 
			
		||||
@@ -207,7 +236,9 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
			type: 'post',
 | 
			
		||||
			text: event.detail.value,
 | 
			
		||||
			root: this.selected.id,
 | 
			
		||||
			branch: this.selected.updates.length ? this.selected.updates[this.selected.updates.length - 1].id : this.selected.id,
 | 
			
		||||
			branch: this.selected.updates.length
 | 
			
		||||
				? this.selected.updates[this.selected.updates.length - 1].id
 | 
			
		||||
				: this.selected.id,
 | 
			
		||||
			issues: [
 | 
			
		||||
				{
 | 
			
		||||
					link: this.selected.id,
 | 
			
		||||
@@ -226,16 +257,18 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
			return html`
 | 
			
		||||
				${header}
 | 
			
		||||
				<div>
 | 
			
		||||
					<input type="button" value="Back" @click=${() => this.selected = undefined}></input>
 | 
			
		||||
					${this.selected.open ?
 | 
			
		||||
						html`<input type="button" value="Close Issue" @click=${() => this.set_open(this.selected.id, false)}></input>` :
 | 
			
		||||
						html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>`}
 | 
			
		||||
					<input type="button" value="Back" @click=${() => (this.selected = undefined)}></input>
 | 
			
		||||
					${
 | 
			
		||||
						this.selected.open
 | 
			
		||||
							? html`<input type="button" value="Close Issue" @click=${() => this.set_open(this.selected.id, false)}></input>`
 | 
			
		||||
							: html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>`
 | 
			
		||||
					}
 | 
			
		||||
				</div>
 | 
			
		||||
				<div>${new Date(this.selected.created).toLocaleString()}</div>
 | 
			
		||||
				<div>${this.selected.author}</div>
 | 
			
		||||
				<div>${this.selected.id}</div>
 | 
			
		||||
				<div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div>
 | 
			
		||||
				${this.selected.updates.map(x => this.render_update(x))}
 | 
			
		||||
				${this.selected.updates.map((x) => this.render_update(x))}
 | 
			
		||||
				<tf-compose @tf-submit=${this.reply_to_issue}></tf-compose>
 | 
			
		||||
			`;
 | 
			
		||||
		} else {
 | 
			
		||||
@@ -250,11 +283,11 @@ class TfIssuesAppElement extends LitElement {
 | 
			
		||||
						<th>Title</th>
 | 
			
		||||
						<th>Date</th>
 | 
			
		||||
					</tr>
 | 
			
		||||
					${this.issues.map(x => this.render_issue_table_row(x))}
 | 
			
		||||
					${this.issues.map((x) => this.render_issue_table_row(x))}
 | 
			
		||||
				</table>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-issues-app', TfIssuesAppElement);
 | 
			
		||||
customElements.define('tf-issues-app', TfIssuesAppElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,20 +1,32 @@
 | 
			
		||||
import * as linkify from './commonmark-linkify.js';
 | 
			
		||||
 | 
			
		||||
function image(node, entering) {
 | 
			
		||||
	if (node.firstChild?.type === 'text' &&
 | 
			
		||||
		node.firstChild.literal.startsWith('video:')) {
 | 
			
		||||
	if (
 | 
			
		||||
		node.firstChild?.type === 'text' &&
 | 
			
		||||
		node.firstChild.literal.startsWith('video:')
 | 
			
		||||
	) {
 | 
			
		||||
		if (entering) {
 | 
			
		||||
			this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>');
 | 
			
		||||
			this.lit(
 | 
			
		||||
				'<video style="max-width: 100%; max-height: 480px" title="' +
 | 
			
		||||
					this.esc(node.firstChild?.literal) +
 | 
			
		||||
					'" controls>'
 | 
			
		||||
			);
 | 
			
		||||
			this.lit('<source src="' + this.esc(node.destination) + '"></source>');
 | 
			
		||||
			this.disableTags += 1;
 | 
			
		||||
		} else {
 | 
			
		||||
			this.disableTags -= 1;
 | 
			
		||||
			this.lit('</video>');
 | 
			
		||||
		}
 | 
			
		||||
	} else if (node.firstChild?.type === 'text' &&
 | 
			
		||||
		node.firstChild.literal.startsWith('audio:')) {
 | 
			
		||||
	} else if (
 | 
			
		||||
		node.firstChild?.type === 'text' &&
 | 
			
		||||
		node.firstChild.literal.startsWith('audio:')
 | 
			
		||||
	) {
 | 
			
		||||
		if (entering) {
 | 
			
		||||
			this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>');
 | 
			
		||||
			this.lit(
 | 
			
		||||
				'<audio style="height: 32px; max-width: 100%" title="' +
 | 
			
		||||
					this.esc(node.firstChild?.literal) +
 | 
			
		||||
					'" controls>'
 | 
			
		||||
			);
 | 
			
		||||
			this.lit('<source src="' + this.esc(node.destination) + '"></source>');
 | 
			
		||||
			this.disableTags += 1;
 | 
			
		||||
		} else {
 | 
			
		||||
@@ -24,7 +36,11 @@ function image(node, entering) {
 | 
			
		||||
	} else {
 | 
			
		||||
		if (entering) {
 | 
			
		||||
			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)) {
 | 
			
		||||
					this.lit('<img src="" alt="');
 | 
			
		||||
				} else {
 | 
			
		||||
@@ -56,14 +72,20 @@ export function markdown(md) {
 | 
			
		||||
		node = event.node;
 | 
			
		||||
		if (event.entering) {
 | 
			
		||||
			if (node.type == 'link') {
 | 
			
		||||
				if (node.destination.startsWith('@') &&
 | 
			
		||||
					node.destination.endsWith('.ed25519')) {
 | 
			
		||||
				if (
 | 
			
		||||
					node.destination.startsWith('@') &&
 | 
			
		||||
					node.destination.endsWith('.ed25519')
 | 
			
		||||
				) {
 | 
			
		||||
					node.destination = '#' + node.destination;
 | 
			
		||||
				} else if (node.destination.startsWith('%') &&
 | 
			
		||||
					node.destination.endsWith('.sha256')) {
 | 
			
		||||
				} else if (
 | 
			
		||||
					node.destination.startsWith('%') &&
 | 
			
		||||
					node.destination.endsWith('.sha256')
 | 
			
		||||
				) {
 | 
			
		||||
					node.destination = '#' + node.destination;
 | 
			
		||||
				} else if (node.destination.startsWith('&') &&
 | 
			
		||||
					node.destination.endsWith('.sha256')) {
 | 
			
		||||
				} else if (
 | 
			
		||||
					node.destination.startsWith('&') &&
 | 
			
		||||
					node.destination.endsWith('.sha256')
 | 
			
		||||
				) {
 | 
			
		||||
					node.destination = '/' + node.destination + '/view';
 | 
			
		||||
				}
 | 
			
		||||
			} else if (node.type == 'image') {
 | 
			
		||||
@@ -88,4 +110,4 @@ export function human_readable_size(bytes) {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return `${Math.round(v * 10) / 10} ${u}`;
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "📝",
 | 
			
		||||
  "previous": "&2hdIDbBrAg63T2X1MzdGSF7yiqHvlnfF0PnInQLp0DA=.sha256"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "📝",
 | 
			
		||||
	"previous": "&b//KqE4Vx6kOSBRODK1p/8wjOLKZJ+CBB5IkaBt5YsM=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -47,7 +47,7 @@ tfrpc.register(async function get_blob(id) {
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
let g_new_message_resolve;
 | 
			
		||||
let g_new_message_promise = new Promise(function(resolve, reject) {
 | 
			
		||||
let g_new_message_promise = new Promise(function (resolve, reject) {
 | 
			
		||||
	g_new_message_resolve = resolve;
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@@ -55,9 +55,9 @@ function new_message() {
 | 
			
		||||
	return g_new_message_promise;
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
ssb.addEventListener('message', function(id) {
 | 
			
		||||
ssb.addEventListener('message', function (id) {
 | 
			
		||||
	let resolve = g_new_message_resolve;
 | 
			
		||||
	g_new_message_promise = new Promise(function(resolve, reject) {
 | 
			
		||||
	g_new_message_promise = new Promise(function (resolve, reject) {
 | 
			
		||||
		g_new_message_resolve = resolve;
 | 
			
		||||
	});
 | 
			
		||||
	if (resolve) {
 | 
			
		||||
@@ -104,8 +104,7 @@ async function process_message(whoami, collection, message, kind, parent) {
 | 
			
		||||
		if (!x) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
		if (content.type !== kind ||
 | 
			
		||||
			(parent && content.parent !== parent)) {
 | 
			
		||||
		if (content.type !== kind || (parent && content.parent !== parent)) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -113,7 +112,10 @@ async function process_message(whoami, collection, message, kind, parent) {
 | 
			
		||||
		if (content?.tombstone) {
 | 
			
		||||
			delete collection[content.key];
 | 
			
		||||
		} else {
 | 
			
		||||
			collection[content.key] = Object.assign(collection[content.key] || {}, content);
 | 
			
		||||
			collection[content.key] = Object.assign(
 | 
			
		||||
				collection[content.key] || {},
 | 
			
		||||
				content
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
	} else {
 | 
			
		||||
		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();
 | 
			
		||||
	data = data ?? {};
 | 
			
		||||
	let rowid = 0;
 | 
			
		||||
	await ssb.sqlAsync('SELECT MAX(rowid) AS rowid FROM messages', [], function(row) {
 | 
			
		||||
		rowid = row.rowid;
 | 
			
		||||
	});
 | 
			
		||||
	await ssb.sqlAsync(
 | 
			
		||||
		'SELECT MAX(rowid) AS rowid FROM messages',
 | 
			
		||||
		[],
 | 
			
		||||
		function (row) {
 | 
			
		||||
			rowid = row.rowid;
 | 
			
		||||
		}
 | 
			
		||||
	);
 | 
			
		||||
	while (true) {
 | 
			
		||||
		if (rowid == max_rowid) {
 | 
			
		||||
			await new_message();
 | 
			
		||||
			await ssb.sqlAsync('SELECT MAX(rowid) AS rowid FROM messages', [], function(row) {
 | 
			
		||||
				rowid = row.rowid;
 | 
			
		||||
			});
 | 
			
		||||
			await ssb.sqlAsync(
 | 
			
		||||
				'SELECT MAX(rowid) AS rowid FROM messages',
 | 
			
		||||
				[],
 | 
			
		||||
				function (row) {
 | 
			
		||||
					rowid = row.rowid;
 | 
			
		||||
				}
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		let modified = false;
 | 
			
		||||
		let rows = [];
 | 
			
		||||
		await ssb.sqlAsync(`
 | 
			
		||||
		await ssb.sqlAsync(
 | 
			
		||||
			`
 | 
			
		||||
			SELECT messages.id, author, content, timestamp
 | 
			
		||||
			FROM messages
 | 
			
		||||
			JOIN json_each(?1) AS id ON messages.author = id.value
 | 
			
		||||
@@ -150,9 +161,10 @@ tfrpc.register(async function collection(ids, kind, parent, max_rowid, data) {
 | 
			
		||||
				content LIKE '"%')
 | 
			
		||||
			`,
 | 
			
		||||
			[JSON.stringify(ids), max_rowid ?? -1, rowid, kind, parent],
 | 
			
		||||
			function(row) {
 | 
			
		||||
			function (row) {
 | 
			
		||||
				rows.push(row);
 | 
			
		||||
			});
 | 
			
		||||
			}
 | 
			
		||||
		);
 | 
			
		||||
		max_rowid = rowid;
 | 
			
		||||
		for (let row of rows) {
 | 
			
		||||
			if (await process_message(whoami, data, row, kind, parent)) {
 | 
			
		||||
@@ -170,4 +182,4 @@ async function main() {
 | 
			
		||||
	await app.setDocument(utf8Decode(await getFile('index.html')));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,16 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html>
 | 
			
		||||
	<head>
 | 
			
		||||
		<base target="_top">
 | 
			
		||||
		<base target="_top" />
 | 
			
		||||
	</head>
 | 
			
		||||
	<body style="color: #fff">
 | 
			
		||||
		<tf-journal-app></tf-journal-app>
 | 
			
		||||
		<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-entry.js" type="module"></script>
 | 
			
		||||
		<script src="tf-id-picker.js" type="module"></script>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -2,8 +2,8 @@ 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.
 | 
			
		||||
*/
 | 
			
		||||
 ** Provide a list of IDs, and this lets the user pick one.
 | 
			
		||||
 */
 | 
			
		||||
class TfIdentityPickerElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
@@ -19,18 +19,25 @@ class TfIdentityPickerElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	changed(event) {
 | 
			
		||||
		this.selected = event.srcElement.value;
 | 
			
		||||
		this.dispatchEvent(new Event('change', {
 | 
			
		||||
			srcElement: this,
 | 
			
		||||
		}));
 | 
			
		||||
		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>`)}
 | 
			
		||||
				${(this.ids ?? []).map(
 | 
			
		||||
					(id) =>
 | 
			
		||||
						html`<option ?selected=${id == this.selected} value=${id}>
 | 
			
		||||
							${id}
 | 
			
		||||
						</option>`
 | 
			
		||||
				)}
 | 
			
		||||
			</select>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-id-picker', TfIdentityPickerElement);
 | 
			
		||||
customElements.define('tf-id-picker', TfIdentityPickerElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -28,9 +28,14 @@ class TfJournalAppElement extends LitElement {
 | 
			
		||||
	async read_journals() {
 | 
			
		||||
		let max_rowid;
 | 
			
		||||
		let journals;
 | 
			
		||||
		while (true)
 | 
			
		||||
		{
 | 
			
		||||
			[max_rowid, journals] = await tfrpc.rpc.collection([this.whoami], 'journal-entry', undefined, max_rowid, journals);
 | 
			
		||||
		while (true) {
 | 
			
		||||
			[max_rowid, journals] = await tfrpc.rpc.collection(
 | 
			
		||||
				[this.whoami],
 | 
			
		||||
				'journal-entry',
 | 
			
		||||
				undefined,
 | 
			
		||||
				max_rowid,
 | 
			
		||||
				journals
 | 
			
		||||
			);
 | 
			
		||||
			this.journals = Object.assign({}, journals);
 | 
			
		||||
			console.log('JOURNALS', this.journals);
 | 
			
		||||
		}
 | 
			
		||||
@@ -52,7 +57,11 @@ class TfJournalAppElement extends LitElement {
 | 
			
		||||
		};
 | 
			
		||||
		message.recps = [this.whoami];
 | 
			
		||||
		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);
 | 
			
		||||
		await tfrpc.rpc.appendMessage(this.whoami, message);
 | 
			
		||||
	}
 | 
			
		||||
@@ -62,14 +71,19 @@ class TfJournalAppElement extends LitElement {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return html`
 | 
			
		||||
			<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>
 | 
			
		||||
			<tf-journal-entry
 | 
			
		||||
				whoami=${this.whoami}
 | 
			
		||||
				.journals=${this.journals}
 | 
			
		||||
				@publish=${this.on_journal_publish}></tf-journal-entry>
 | 
			
		||||
				@publish=${this.on_journal_publish}
 | 
			
		||||
			></tf-journal-entry>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-journal-app', TfJournalAppElement);
 | 
			
		||||
customElements.define('tf-journal-app', TfJournalAppElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -30,13 +30,15 @@ class TfJournalEntryElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	async on_publish() {
 | 
			
		||||
		console.log('publish', this.text);
 | 
			
		||||
		this.dispatchEvent(new CustomEvent('publish', {
 | 
			
		||||
			bubbles: true,
 | 
			
		||||
			detail: {
 | 
			
		||||
				key: this.shadowRoot.getElementById('date_picker').value,
 | 
			
		||||
				text: this.text,
 | 
			
		||||
			},
 | 
			
		||||
		}));
 | 
			
		||||
		this.dispatchEvent(
 | 
			
		||||
			new CustomEvent('publish', {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				detail: {
 | 
			
		||||
					key: this.shadowRoot.getElementById('date_picker').value,
 | 
			
		||||
					text: this.text,
 | 
			
		||||
				},
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	back_dates(count) {
 | 
			
		||||
@@ -63,22 +65,33 @@ class TfJournalEntryElement extends LitElement {
 | 
			
		||||
		console.log('RENDER ENTRY', this.key, this.journals?.[this.key]);
 | 
			
		||||
		return html`
 | 
			
		||||
			<select id="date_picker" @change=${this.on_date_change}>
 | 
			
		||||
				${this.back_dates(10).map(x => html`
 | 
			
		||||
					<option value=${x}>${x}</option>
 | 
			
		||||
				`)}
 | 
			
		||||
				${this.back_dates(10).map(
 | 
			
		||||
					(x) => html` <option value=${x}>${x}</option> `
 | 
			
		||||
				)}
 | 
			
		||||
			</select>
 | 
			
		||||
			<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>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div style="display: flex; flex-direction: row">
 | 
			
		||||
				<textarea
 | 
			
		||||
					style="flex: 1 1; min-height: 10em"
 | 
			
		||||
					@input=${this.on_edit} .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>
 | 
			
		||||
					@input=${this.on_edit}
 | 
			
		||||
					.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>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-journal-entry', TfJournalEntryElement);
 | 
			
		||||
customElements.define('tf-journal-entry', TfJournalEntryElement);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										5
									
								
								apps/room.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								apps/room.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "📦",
 | 
			
		||||
	"previous": "&IU+TwyM7TznD8NBfnw7tgW2zxVlMqTVxSqWFjuosLwo=.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",
 | 
			
		||||
  "emoji": "👟"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "👟",
 | 
			
		||||
	"previous": "&lYZRnT2UGQxXxYISbuaZewik9AuxBpcJumakwrePw5c=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,4 +27,4 @@ tfrpc.register(async function store_message(message) {
 | 
			
		||||
async function main() {
 | 
			
		||||
	await app.setDocument(utf8Decode(await getFile('index.html')));
 | 
			
		||||
}
 | 
			
		||||
main();
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,16 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html style="color: #fff">
 | 
			
		||||
	<head>
 | 
			
		||||
		<title>Tilde Friends</title>
 | 
			
		||||
		<base target="_top">
 | 
			
		||||
		<base target="_top" />
 | 
			
		||||
	</head>
 | 
			
		||||
	<body>
 | 
			
		||||
		<tf-sneaker-app/>
 | 
			
		||||
		<script>window.litDisableBundleWarning = true;</script>
 | 
			
		||||
		<tf-sneaker-app />
 | 
			
		||||
		<script>
 | 
			
		||||
			window.litDisableBundleWarning = true;
 | 
			
		||||
		</script>
 | 
			
		||||
		<script src="filesaver.min.js"></script>
 | 
			
		||||
		<script src="jszip.min.js"></script>
 | 
			
		||||
		<script src="script.js" type="module"></script>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -19,7 +19,8 @@ class TfSneakerAppElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	async search() {
 | 
			
		||||
		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
 | 
			
		||||
			FROM messages_fts(?)
 | 
			
		||||
			JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
@@ -31,15 +32,17 @@ class TfSneakerAppElement extends LitElement {
 | 
			
		||||
			HAVING MAX(messages.sequence)
 | 
			
		||||
			ORDER BY COUNT(*) DESC
 | 
			
		||||
			`,
 | 
			
		||||
			[`"${q.replaceAll('"', '""')}"`]);
 | 
			
		||||
		this.feeds = Object.fromEntries(result.map(x => [x.id, x.name]));
 | 
			
		||||
			[`"${q.replaceAll('"', '""')}"`]
 | 
			
		||||
		);
 | 
			
		||||
		this.feeds = Object.fromEntries(result.map((x) => [x.id, x.name]));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	format_message(message) {
 | 
			
		||||
		const k_flag_sequence_before_author = 1;
 | 
			
		||||
		let out = {
 | 
			
		||||
			previous: message.previous ?? null,
 | 
			
		||||
		};
 | 
			
		||||
		if (message.sequence_before_author) {
 | 
			
		||||
		if (message.flags & k_flag_sequence_before_author) {
 | 
			
		||||
			out.sequence = message.sequence;
 | 
			
		||||
			out.author = message.author;
 | 
			
		||||
		} else {
 | 
			
		||||
@@ -70,24 +73,104 @@ class TfSneakerAppElement extends LitElement {
 | 
			
		||||
			return true;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		if (startsWith(data, [0xff, 0xd8, 0xff, 0xdb]) ||
 | 
			
		||||
			startsWith(data, [0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]) ||
 | 
			
		||||
		if (
 | 
			
		||||
			startsWith(data, [0xff, 0xd8, 0xff, 0xdb]) ||
 | 
			
		||||
			startsWith(
 | 
			
		||||
				data,
 | 
			
		||||
				[0xff, 0xd8, 0xff, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]
 | 
			
		||||
			) ||
 | 
			
		||||
			startsWith(data, [0xff, 0xd8, 0xff, 0xee]) ||
 | 
			
		||||
			startsWith(data, [0xff, 0xd8, 0xff, 0xe1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00])) {
 | 
			
		||||
			startsWith(data, [
 | 
			
		||||
				0xff,
 | 
			
		||||
				0xd8,
 | 
			
		||||
				0xff,
 | 
			
		||||
				0xe1,
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				0x45,
 | 
			
		||||
				0x78,
 | 
			
		||||
				0x69,
 | 
			
		||||
				0x66,
 | 
			
		||||
				0x00,
 | 
			
		||||
				0x00,
 | 
			
		||||
			])
 | 
			
		||||
		) {
 | 
			
		||||
			return '.jpg';
 | 
			
		||||
		} else if (startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
 | 
			
		||||
		} else if (
 | 
			
		||||
			startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
 | 
			
		||||
		) {
 | 
			
		||||
			return '.png';
 | 
			
		||||
		} else if (startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
 | 
			
		||||
			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) {
 | 
			
		||||
		} else if (
 | 
			
		||||
			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
 | 
			
		||||
			startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
 | 
			
		||||
		) {
 | 
			
		||||
			return '.gif';
 | 
			
		||||
		} else if (startsWith(data, [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50])) {
 | 
			
		||||
		} else if (
 | 
			
		||||
			startsWith(data, [
 | 
			
		||||
				0x52,
 | 
			
		||||
				0x49,
 | 
			
		||||
				0x46,
 | 
			
		||||
				0x46,
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				0x57,
 | 
			
		||||
				0x45,
 | 
			
		||||
				0x42,
 | 
			
		||||
				0x50,
 | 
			
		||||
			])
 | 
			
		||||
		) {
 | 
			
		||||
			return '.webp';
 | 
			
		||||
		} else if (startsWith(data, [0x3c, 0x73, 0x76, 0x67])) {
 | 
			
		||||
			return '.svg';
 | 
			
		||||
		} else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
 | 
			
		||||
		} else if (
 | 
			
		||||
			startsWith(data, [
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				0x66,
 | 
			
		||||
				0x74,
 | 
			
		||||
				0x79,
 | 
			
		||||
				0x70,
 | 
			
		||||
				0x6d,
 | 
			
		||||
				0x70,
 | 
			
		||||
				0x34,
 | 
			
		||||
				0x32,
 | 
			
		||||
			])
 | 
			
		||||
		) {
 | 
			
		||||
			return '.mp3';
 | 
			
		||||
		} else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]) ||
 | 
			
		||||
			startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
 | 
			
		||||
		} else if (
 | 
			
		||||
			startsWith(data, [
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				0x66,
 | 
			
		||||
				0x74,
 | 
			
		||||
				0x79,
 | 
			
		||||
				0x70,
 | 
			
		||||
				0x69,
 | 
			
		||||
				0x73,
 | 
			
		||||
				0x6f,
 | 
			
		||||
				0x6d,
 | 
			
		||||
			]) ||
 | 
			
		||||
			startsWith(data, [
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				null,
 | 
			
		||||
				0x66,
 | 
			
		||||
				0x74,
 | 
			
		||||
				0x79,
 | 
			
		||||
				0x70,
 | 
			
		||||
				0x6d,
 | 
			
		||||
				0x70,
 | 
			
		||||
				0x34,
 | 
			
		||||
				0x32,
 | 
			
		||||
			])
 | 
			
		||||
		) {
 | 
			
		||||
			return '.mp4';
 | 
			
		||||
		} else {
 | 
			
		||||
			return '.bin';
 | 
			
		||||
@@ -98,17 +181,34 @@ class TfSneakerAppElement extends LitElement {
 | 
			
		||||
		let all_messages = '';
 | 
			
		||||
		let sequence = -1;
 | 
			
		||||
		let messages_done = 0;
 | 
			
		||||
		let messages_max = (await tfrpc.rpc.query('SELECT MAX(sequence) AS total FROM messages WHERE author = ?', [id]))[0].total;
 | 
			
		||||
		let messages_max = (
 | 
			
		||||
			await tfrpc.rpc.query(
 | 
			
		||||
				'SELECT MAX(sequence) AS total FROM messages WHERE author = ?',
 | 
			
		||||
				[id]
 | 
			
		||||
			)
 | 
			
		||||
		)[0].total;
 | 
			
		||||
		while (true) {
 | 
			
		||||
			let messages = await tfrpc.rpc.query(
 | 
			
		||||
					'SELECT * FROM messages WHERE author = ? AND SEQUENCE > ? ORDER BY sequence LIMIT 100',
 | 
			
		||||
					[id, sequence]
 | 
			
		||||
				`
 | 
			
		||||
				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]
 | 
			
		||||
			);
 | 
			
		||||
			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;
 | 
			
		||||
				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 {
 | 
			
		||||
				break;
 | 
			
		||||
			}
 | 
			
		||||
@@ -122,7 +222,8 @@ class TfSneakerAppElement extends LitElement {
 | 
			
		||||
			FROM messages
 | 
			
		||||
			JOIN messages_refs ON messages.id = messages_refs.message
 | 
			
		||||
			WHERE messages.author = ? AND messages_refs.ref LIKE '&%.sha256'`,
 | 
			
		||||
			[id]);
 | 
			
		||||
			[id]
 | 
			
		||||
		);
 | 
			
		||||
		let blobs_done = 0;
 | 
			
		||||
		for (let row of blobs) {
 | 
			
		||||
			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}`);
 | 
			
		||||
			}
 | 
			
		||||
			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++;
 | 
			
		||||
		}
 | 
			
		||||
@@ -161,7 +265,7 @@ class TfSneakerAppElement extends LitElement {
 | 
			
		||||
		file = await zip.loadAsync(file);
 | 
			
		||||
		let messages = [];
 | 
			
		||||
		let blobs = [];
 | 
			
		||||
		file.forEach(function(path, entry) {
 | 
			
		||||
		file.forEach(function (path, entry) {
 | 
			
		||||
			if (!entry.dir) {
 | 
			
		||||
				if (path.startsWith('message/classic/')) {
 | 
			
		||||
					messages.push(entry);
 | 
			
		||||
@@ -181,7 +285,11 @@ class TfSneakerAppElement extends LitElement {
 | 
			
		||||
					continue;
 | 
			
		||||
				}
 | 
			
		||||
				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)) {
 | 
			
		||||
					success.messages++;
 | 
			
		||||
				}
 | 
			
		||||
@@ -202,7 +310,13 @@ class TfSneakerAppElement extends LitElement {
 | 
			
		||||
		let progress;
 | 
			
		||||
		if (this.progress) {
 | 
			
		||||
			if (this.progress.max) {
 | 
			
		||||
				progress = html`<div><label for="progress">${this.progress.name}</label><progress value=${this.progress.value} max=${this.progress.max}></progress></div>`;
 | 
			
		||||
				progress = html`<div>
 | 
			
		||||
					<label for="progress">${this.progress.name}</label
 | 
			
		||||
					><progress
 | 
			
		||||
						value=${this.progress.value}
 | 
			
		||||
						max=${this.progress.max}
 | 
			
		||||
					></progress>
 | 
			
		||||
				</div>`;
 | 
			
		||||
			} else {
 | 
			
		||||
				progress = html`<div><span>${this.progress.name}</span></div>`;
 | 
			
		||||
			}
 | 
			
		||||
@@ -218,15 +332,19 @@ class TfSneakerAppElement extends LitElement {
 | 
			
		||||
			<input type="text" id="search" @keypress=${this.keypress}></input>
 | 
			
		||||
			<input type="button" value="Search Users" @click=${this.search}></input>
 | 
			
		||||
			<ul>
 | 
			
		||||
				${Object.entries(this.feeds).map(([id, name]) => html`
 | 
			
		||||
					<li>
 | 
			
		||||
						${this.progress ? undefined : html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`}
 | 
			
		||||
						${name}
 | 
			
		||||
						<code style="color: #ccc">${id}</code>
 | 
			
		||||
					</li>
 | 
			
		||||
				`)}
 | 
			
		||||
				${Object.entries(this.feeds).map(
 | 
			
		||||
					([id, name]) => html`
 | 
			
		||||
						<li>
 | 
			
		||||
							${this.progress
 | 
			
		||||
								? undefined
 | 
			
		||||
								: html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`}
 | 
			
		||||
							${name}
 | 
			
		||||
							<code style="color: #ccc">${id}</code>
 | 
			
		||||
						</li>
 | 
			
		||||
					`
 | 
			
		||||
				)}
 | 
			
		||||
			</ul>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
customElements.define('tf-sneaker-app', TfSneakerAppElement);
 | 
			
		||||
customElements.define('tf-sneaker-app', TfSneakerAppElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "🐌",
 | 
			
		||||
  "previous": "&DUxMMCJcuhm6S9jg/eKgEyWodkITu6Tg9g5I5wgLWFU=.sha256"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "🐌",
 | 
			
		||||
	"previous": "&Xs1X5TzLCk6KVr+5IDc80JAHYxJyoD10cXKBUYpFqWQ=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -76,7 +76,7 @@ tfrpc.register(function getHash(id, message) {
 | 
			
		||||
tfrpc.register(function setHash(hash) {
 | 
			
		||||
	return app.setHash(hash);
 | 
			
		||||
});
 | 
			
		||||
ssb.addEventListener('message', async function(id) {
 | 
			
		||||
ssb.addEventListener('message', async function (id) {
 | 
			
		||||
	await tfrpc.rpc.notifyNewMessage(id);
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function store_blob(blob) {
 | 
			
		||||
@@ -100,18 +100,18 @@ tfrpc.register(async function try_decrypt(id, content) {
 | 
			
		||||
tfrpc.register(async function encrypt(id, recipients, content) {
 | 
			
		||||
	return await ssb.privateMessageEncrypt(id, recipients, content);
 | 
			
		||||
});
 | 
			
		||||
ssb.addEventListener('broadcasts', async function() {
 | 
			
		||||
ssb.addEventListener('broadcasts', async function () {
 | 
			
		||||
	await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts());
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
core.register('onConnectionsChanged', async function() {
 | 
			
		||||
core.register('onConnectionsChanged', async function () {
 | 
			
		||||
	await tfrpc.rpc.set('connections', await ssb.connections());
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	if (typeof(database) !== 'undefined') {
 | 
			
		||||
	if (typeof database !== 'undefined') {
 | 
			
		||||
		g_database = await database('ssb');
 | 
			
		||||
	}
 | 
			
		||||
	await app.setDocument(utf8Decode(await getFile('index.html')));
 | 
			
		||||
}
 | 
			
		||||
main();
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -4,14 +4,14 @@ function get_emojis() {
 | 
			
		||||
	if (g_emojis) {
 | 
			
		||||
		return Promise.resolve(g_emojis);
 | 
			
		||||
	}
 | 
			
		||||
	return fetch('emojis.json').then(function(result) {
 | 
			
		||||
	return fetch('emojis.json').then(function (result) {
 | 
			
		||||
		g_emojis = result.json();
 | 
			
		||||
		return g_emojis;
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
export function picker(callback, anchor) {
 | 
			
		||||
	get_emojis().then(function(json) {
 | 
			
		||||
	get_emojis().then(function (json) {
 | 
			
		||||
		let div = document.createElement('div');
 | 
			
		||||
		div.id = 'emoji_picker';
 | 
			
		||||
		div.style.color = '#000';
 | 
			
		||||
@@ -36,7 +36,7 @@ export function picker(callback, anchor) {
 | 
			
		||||
		div.appendChild(input);
 | 
			
		||||
		let list = document.createElement('div');
 | 
			
		||||
		div.appendChild(list);
 | 
			
		||||
		div.addEventListener('mousedown', function(event) {
 | 
			
		||||
		div.addEventListener('mousedown', function (event) {
 | 
			
		||||
			event.stopPropagation();
 | 
			
		||||
		});
 | 
			
		||||
 | 
			
		||||
@@ -72,9 +72,11 @@ export function picker(callback, anchor) {
 | 
			
		||||
				list.appendChild(header);
 | 
			
		||||
				let any = false;
 | 
			
		||||
				for (let entry of Object.entries(row[1])) {
 | 
			
		||||
					if (search &&
 | 
			
		||||
					if (
 | 
			
		||||
						search &&
 | 
			
		||||
						search.length &&
 | 
			
		||||
						entry[0].toLowerCase().indexOf(search) == -1) {
 | 
			
		||||
						entry[0].toLowerCase().indexOf(search) == -1
 | 
			
		||||
					) {
 | 
			
		||||
						continue;
 | 
			
		||||
					}
 | 
			
		||||
					let emoji = document.createElement('span');
 | 
			
		||||
@@ -109,4 +111,4 @@ export function picker(callback, anchor) {
 | 
			
		||||
		document.body.addEventListener('mousedown', cleanup);
 | 
			
		||||
		window.addEventListener('keydown', key_down);
 | 
			
		||||
	});
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,9 +1,9 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html style="color: #fff">
 | 
			
		||||
	<head>
 | 
			
		||||
		<title>Tilde Friends</title>
 | 
			
		||||
		<base target="_top">
 | 
			
		||||
		<link rel="stylesheet" href="tribute.css"/>
 | 
			
		||||
		<base target="_top" />
 | 
			
		||||
		<link rel="stylesheet" href="tribute.css" />
 | 
			
		||||
		<style>
 | 
			
		||||
			.tribute-container {
 | 
			
		||||
				color: #000;
 | 
			
		||||
@@ -11,12 +11,14 @@
 | 
			
		||||
		</style>
 | 
			
		||||
	</head>
 | 
			
		||||
	<body style="background-color: #223a5e">
 | 
			
		||||
		<tf-app class="w3-deep-purple"/>
 | 
			
		||||
		<script>window.litDisableBundleWarning = true;</script>
 | 
			
		||||
		<tf-app class="w3-deep-purple" />
 | 
			
		||||
		<script>
 | 
			
		||||
			window.litDisableBundleWarning = true;
 | 
			
		||||
		</script>
 | 
			
		||||
		<script src="filesaver.min.js"></script>
 | 
			
		||||
		<script src="commonmark.min.js"></script>
 | 
			
		||||
		<script src="commonmark-linkify.js" type="module"></script>
 | 
			
		||||
		<script src="commonmark-hashtag.js" type="module"></script>
 | 
			
		||||
		<script src="script.js" type="module"></script>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -14,4 +14,4 @@ import * as tf_tab_news_feed from './tf-tab-news-feed.js';
 | 
			
		||||
import * as tf_tab_search from './tf-tab-search.js';
 | 
			
		||||
import * as tf_tab_connections from './tf-tab-connections.js';
 | 
			
		||||
import * as tf_tab_query from './tf-tab-query.js';
 | 
			
		||||
import * as tf_tag from './tf-tag.js';
 | 
			
		||||
import * as tf_tag from './tf-tag.js';
 | 
			
		||||
 
 | 
			
		||||
@@ -34,9 +34,13 @@ class TfElement extends LitElement {
 | 
			
		||||
		this.users = {};
 | 
			
		||||
		this.loaded = false;
 | 
			
		||||
		this.tags = [];
 | 
			
		||||
		tfrpc.rpc.getBroadcasts().then(b => { self.broadcasts = b || []; });
 | 
			
		||||
		tfrpc.rpc.getConnections().then(c => { self.connections = c || []; });
 | 
			
		||||
		tfrpc.rpc.getHash().then(hash => self.set_hash(hash));
 | 
			
		||||
		tfrpc.rpc.getBroadcasts().then((b) => {
 | 
			
		||||
			self.broadcasts = b || [];
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.rpc.getConnections().then((c) => {
 | 
			
		||||
			self.connections = c || [];
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.rpc.getHash().then((hash) => self.set_hash(hash));
 | 
			
		||||
		tfrpc.register(function hashChanged(hash) {
 | 
			
		||||
			self.set_hash(hash);
 | 
			
		||||
		});
 | 
			
		||||
@@ -86,9 +90,14 @@ class TfElement extends LitElement {
 | 
			
		||||
				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
 | 
			
		||||
		`, []))[0].max_row_id;
 | 
			
		||||
		`,
 | 
			
		||||
				[]
 | 
			
		||||
			)
 | 
			
		||||
		)[0].max_row_id;
 | 
			
		||||
		for (let id of Object.keys(cache.about)) {
 | 
			
		||||
			if (ids.indexOf(id) == -1) {
 | 
			
		||||
				delete cache.about[id];
 | 
			
		||||
@@ -98,7 +107,7 @@ class TfElement extends LitElement {
 | 
			
		||||
		let abouts = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				SELECT
 | 
			
		||||
					messages.*
 | 
			
		||||
					messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
				FROM
 | 
			
		||||
					messages,
 | 
			
		||||
					json_each(?1) AS following
 | 
			
		||||
@@ -109,7 +118,7 @@ class TfElement extends LitElement {
 | 
			
		||||
					json_extract(messages.content, '$.type') = 'about'
 | 
			
		||||
				UNION
 | 
			
		||||
				SELECT
 | 
			
		||||
					messages.*
 | 
			
		||||
					messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
				FROM
 | 
			
		||||
					messages,
 | 
			
		||||
					json_each(?2) AS following
 | 
			
		||||
@@ -120,17 +129,21 @@ class TfElement extends LitElement {
 | 
			
		||||
				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,
 | 
			
		||||
				max_row_id,
 | 
			
		||||
			]);
 | 
			
		||||
			]
 | 
			
		||||
		);
 | 
			
		||||
		for (let about of abouts) {
 | 
			
		||||
			let content = JSON.parse(about.content);
 | 
			
		||||
			if (content.about === about.author) {
 | 
			
		||||
				delete content.type;
 | 
			
		||||
				delete content.about;
 | 
			
		||||
				cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content);
 | 
			
		||||
				cache.about[about.author] = Object.assign(
 | 
			
		||||
					cache.about[about.author] || {},
 | 
			
		||||
					content
 | 
			
		||||
				);
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		cache.last_row_id = max_row_id;
 | 
			
		||||
@@ -145,15 +158,13 @@ class TfElement extends LitElement {
 | 
			
		||||
	async fetch_new_message(id) {
 | 
			
		||||
		let messages = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				SELECT messages.*
 | 
			
		||||
				SELECT messages.id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
 | 
			
		||||
				FROM messages
 | 
			
		||||
				JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
				WHERE messages.id = ?
 | 
			
		||||
			`,
 | 
			
		||||
			[
 | 
			
		||||
				JSON.stringify(this.following),
 | 
			
		||||
				id,
 | 
			
		||||
			]);
 | 
			
		||||
			[JSON.stringify(this.following), id]
 | 
			
		||||
		);
 | 
			
		||||
		if (messages && messages.length) {
 | 
			
		||||
			this.unread = [...this.unread, ...messages];
 | 
			
		||||
			this.unread = this.unread.slice(this.unread.length - 1024);
 | 
			
		||||
@@ -173,7 +184,7 @@ class TfElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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();
 | 
			
		||||
			this.ids = (await tfrpc.rpc.getIdentities()) || [];
 | 
			
		||||
			if (this.ids && !this.whoami) {
 | 
			
		||||
@@ -185,17 +196,32 @@ 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>
 | 
			
		||||
				<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() {
 | 
			
		||||
		let start = new Date();
 | 
			
		||||
		this.tags = await tfrpc.rpc.query(`
 | 
			
		||||
		this.tags = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
			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'
 | 
			
		||||
					ORDER BY timestamp DESC LIMIT 1024),
 | 
			
		||||
				recent_channels AS (SELECT recent.id, '#' || json_extract(content, '$.channel') AS tag
 | 
			
		||||
@@ -207,7 +233,9 @@ class TfElement extends LitElement {
 | 
			
		||||
				combined AS (SELECT id, tag FROM recent_channels UNION ALL SELECT id, tag FROM recent_mentions),
 | 
			
		||||
				by_message AS (SELECT DISTINCT id, tag FROM combined)
 | 
			
		||||
			SELECT tag, COUNT(*) AS count FROM by_message GROUP BY tag ORDER BY count DESC LIMIT 10
 | 
			
		||||
		`, [new Date() - 7 * 24 * 60 * 60 * 1000]);
 | 
			
		||||
		`,
 | 
			
		||||
			[new Date() - 7 * 24 * 60 * 60 * 1000]
 | 
			
		||||
		);
 | 
			
		||||
		console.log('tags took', (new Date() - start) / 1000.0, 'seconds');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -241,23 +269,53 @@ class TfElement extends LitElement {
 | 
			
		||||
		let users = this.users;
 | 
			
		||||
		if (this.tab === 'news') {
 | 
			
		||||
			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') {
 | 
			
		||||
			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') {
 | 
			
		||||
			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') {
 | 
			
		||||
			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') {
 | 
			
		||||
			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>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -280,7 +338,7 @@ class TfElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
		if (!this.loading && this.whoami && this.loaded !== this.whoami) {
 | 
			
		||||
			this.loading = true;
 | 
			
		||||
			this.load().finally(function() {
 | 
			
		||||
			this.load().finally(function () {
 | 
			
		||||
				self.loading = false;
 | 
			
		||||
			});
 | 
			
		||||
		}
 | 
			
		||||
@@ -295,21 +353,32 @@ class TfElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
		let tabs = html`
 | 
			
		||||
			<div class="w3-bar w3-black">
 | 
			
		||||
				${Object.entries(k_tabs).map(([k, v]) => html`
 | 
			
		||||
				<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>
 | 
			
		||||
				`)}
 | 
			
		||||
				${Object.entries(k_tabs).map(
 | 
			
		||||
					([k, v]) => html`
 | 
			
		||||
						<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>
 | 
			
		||||
					`
 | 
			
		||||
				)}
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
		let contents =
 | 
			
		||||
				!this.loaded ?
 | 
			
		||||
					this.loading ?
 | 
			
		||||
						html`<div>Loading...</div>` :
 | 
			
		||||
						html`<div>Select or create an identity.</div>` :
 | 
			
		||||
					this.render_tab();
 | 
			
		||||
		let contents = !this.loaded
 | 
			
		||||
			? this.loading
 | 
			
		||||
				? html`<div>Loading...</div>`
 | 
			
		||||
				: html`<div>Select or create an identity.</div>`
 | 
			
		||||
			: this.render_tab();
 | 
			
		||||
		return html`
 | 
			
		||||
			${this.render_id_picker()}
 | 
			
		||||
			${tabs}
 | 
			
		||||
			${this.tags.map(x => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>`)}
 | 
			
		||||
			${this.render_id_picker()} ${tabs}
 | 
			
		||||
			${this.tags.map(
 | 
			
		||||
				(x) => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>`
 | 
			
		||||
			)}
 | 
			
		||||
			${contents}
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -58,7 +58,9 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
					link: link,
 | 
			
		||||
				};
 | 
			
		||||
			}
 | 
			
		||||
			draft.mentions[link].name = name.startsWith('@') ? name.substring(1) : name;
 | 
			
		||||
			draft.mentions[link].name = name.startsWith('@')
 | 
			
		||||
				? name.substring(1)
 | 
			
		||||
				: name;
 | 
			
		||||
			updated = true;
 | 
			
		||||
		}
 | 
			
		||||
		if (updated) {
 | 
			
		||||
@@ -72,34 +74,39 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		let preview = this.renderRoot.getElementById('preview');
 | 
			
		||||
		preview.innerHTML = this.process_text(edit.value);
 | 
			
		||||
		let content_warning = this.renderRoot.getElementById('content_warning');
 | 
			
		||||
		let content_warning_preview = this.renderRoot.getElementById('content_warning_preview');
 | 
			
		||||
		let content_warning_preview = this.renderRoot.getElementById(
 | 
			
		||||
			'content_warning_preview'
 | 
			
		||||
		);
 | 
			
		||||
		if (content_warning && content_warning_preview) {
 | 
			
		||||
			content_warning_preview.innerText = content_warning.value;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	notify(draft) {
 | 
			
		||||
		this.dispatchEvent(new CustomEvent('tf-draft', {
 | 
			
		||||
			bubbles: true,
 | 
			
		||||
			composed: true,
 | 
			
		||||
			detail: {
 | 
			
		||||
				id: this.branch,
 | 
			
		||||
				draft: draft
 | 
			
		||||
			},
 | 
			
		||||
		}));
 | 
			
		||||
		this.dispatchEvent(
 | 
			
		||||
			new CustomEvent('tf-draft', {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				composed: true,
 | 
			
		||||
				detail: {
 | 
			
		||||
					id: this.branch,
 | 
			
		||||
					draft: draft,
 | 
			
		||||
				},
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	change() {
 | 
			
		||||
		let draft = this.get_draft();
 | 
			
		||||
		draft.text = this.renderRoot.getElementById('edit')?.value;
 | 
			
		||||
		draft.content_warning = this.renderRoot.getElementById('content_warning')?.value;
 | 
			
		||||
		draft.content_warning =
 | 
			
		||||
			this.renderRoot.getElementById('content_warning')?.value;
 | 
			
		||||
		this.notify(draft);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	convert_to_format(buffer, type, mime_type) {
 | 
			
		||||
		return new Promise(function(resolve, reject) {
 | 
			
		||||
		return new Promise(function (resolve, reject) {
 | 
			
		||||
			let img = new Image();
 | 
			
		||||
			img.onload = function() {
 | 
			
		||||
			img.onload = function () {
 | 
			
		||||
				let canvas = document.createElement('canvas');
 | 
			
		||||
				let width_scale = Math.min(img.width, 1024) / img.width;
 | 
			
		||||
				let height_scale = Math.min(img.height, 1024) / img.height;
 | 
			
		||||
@@ -109,13 +116,17 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
				let context = canvas.getContext('2d');
 | 
			
		||||
				context.drawImage(img, 0, 0, canvas.width, canvas.height);
 | 
			
		||||
				let data_url = canvas.toDataURL(mime_type);
 | 
			
		||||
				let result = atob(data_url.split(',')[1]).split('').map(x => x.charCodeAt(0));
 | 
			
		||||
				let result = atob(data_url.split(',')[1])
 | 
			
		||||
					.split('')
 | 
			
		||||
					.map((x) => x.charCodeAt(0));
 | 
			
		||||
				resolve(result);
 | 
			
		||||
			};
 | 
			
		||||
			img.onerror = function(event) {
 | 
			
		||||
			img.onerror = function (event) {
 | 
			
		||||
				reject(new Error('Failed to load image.'));
 | 
			
		||||
			};
 | 
			
		||||
			let raw = Array.from(new Uint8Array(buffer)).map(b => String.fromCharCode(b)).join('');
 | 
			
		||||
			let raw = Array.from(new Uint8Array(buffer))
 | 
			
		||||
				.map((b) => String.fromCharCode(b))
 | 
			
		||||
				.join('');
 | 
			
		||||
			let original = `data:${type};base64,${btoa(raw)}`;
 | 
			
		||||
			img.src = original;
 | 
			
		||||
		});
 | 
			
		||||
@@ -131,7 +142,11 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
				let best_buffer;
 | 
			
		||||
				let best_type;
 | 
			
		||||
				for (let format of ['image/png', 'image/jpeg', 'image/webp']) {
 | 
			
		||||
					let test_buffer = await self.convert_to_format(buffer, file.type, format);
 | 
			
		||||
					let test_buffer = await self.convert_to_format(
 | 
			
		||||
						buffer,
 | 
			
		||||
						file.type,
 | 
			
		||||
						format
 | 
			
		||||
					);
 | 
			
		||||
					if (!best_buffer || test_buffer.length < best_buffer.length) {
 | 
			
		||||
						best_buffer = test_buffer;
 | 
			
		||||
						best_type = format;
 | 
			
		||||
@@ -157,7 +172,7 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			edit.value += `\n`;
 | 
			
		||||
			self.change();
 | 
			
		||||
			self.input();
 | 
			
		||||
		} catch(e) {
 | 
			
		||||
		} catch (e) {
 | 
			
		||||
			alert(e?.message);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -201,11 +216,15 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			to = [...to];
 | 
			
		||||
			message.recps = to;
 | 
			
		||||
			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);
 | 
			
		||||
		}
 | 
			
		||||
		try {
 | 
			
		||||
			await tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
 | 
			
		||||
			await tfrpc.rpc.appendMessage(this.whoami, message).then(function () {
 | 
			
		||||
				edit.value = '';
 | 
			
		||||
				self.change();
 | 
			
		||||
				self.notify(undefined);
 | 
			
		||||
@@ -230,7 +249,7 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		let edit = this.renderRoot.getElementById('edit');
 | 
			
		||||
		let input = document.createElement('input');
 | 
			
		||||
		input.type = 'file';
 | 
			
		||||
		input.onchange = function(event) {
 | 
			
		||||
		input.onchange = function (event) {
 | 
			
		||||
			let file = event.target.files[0];
 | 
			
		||||
			self.add_file(file);
 | 
			
		||||
		};
 | 
			
		||||
@@ -241,12 +260,15 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		this.last_autocomplete = text;
 | 
			
		||||
		let results = [];
 | 
			
		||||
		try {
 | 
			
		||||
			let rows = await tfrpc.rpc.query(`
 | 
			
		||||
				SELECT messages.content FROM messages_fts(?)
 | 
			
		||||
			let rows = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
				SELECT json(messages.content) FROM messages_fts(?)
 | 
			
		||||
				JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
				WHERE messages.content LIKE ?
 | 
			
		||||
				ORDER BY timestamp DESC LIMIT 10
 | 
			
		||||
			`, ['"' + text.replace('"', '""') + '"', `%%`]);
 | 
			
		||||
			`,
 | 
			
		||||
				['"' + text.replace('"', '""') + '"', `%%`]
 | 
			
		||||
			);
 | 
			
		||||
			for (let row of rows) {
 | 
			
		||||
				for (let match of row.content.matchAll(/!\[([^\]]*)\]\((&.*?)\)/g)) {
 | 
			
		||||
					if (match[1].toLowerCase().indexOf(text.toLowerCase()) != -1) {
 | 
			
		||||
@@ -265,15 +287,18 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		let tribute = new Tribute({
 | 
			
		||||
			collection: [
 | 
			
		||||
				{
 | 
			
		||||
					values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
 | 
			
		||||
					selectTemplate: function(item) {
 | 
			
		||||
					values: Object.entries(this.users).map((x) => ({
 | 
			
		||||
						key: x[1].name,
 | 
			
		||||
						value: x[0],
 | 
			
		||||
					})),
 | 
			
		||||
					selectTemplate: function (item) {
 | 
			
		||||
						return `[@${item.original.key}](${item.original.value})`;
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
				{
 | 
			
		||||
					trigger: '&',
 | 
			
		||||
					values: this.autocomplete,
 | 
			
		||||
					selectTemplate: function(item) {
 | 
			
		||||
					selectTemplate: function (item) {
 | 
			
		||||
						return ``;
 | 
			
		||||
					},
 | 
			
		||||
				},
 | 
			
		||||
@@ -293,8 +318,11 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		let encrypt = this.renderRoot.getElementById('encrypt_to');
 | 
			
		||||
		if (encrypt) {
 | 
			
		||||
			let tribute = new Tribute({
 | 
			
		||||
				values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
 | 
			
		||||
				selectTemplate: function(item) {
 | 
			
		||||
				values: Object.entries(this.users).map((x) => ({
 | 
			
		||||
					key: x[1].name,
 | 
			
		||||
					value: x[0],
 | 
			
		||||
				})),
 | 
			
		||||
				selectTemplate: function (item) {
 | 
			
		||||
					return item.original.value;
 | 
			
		||||
				},
 | 
			
		||||
			});
 | 
			
		||||
@@ -311,20 +339,30 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	render_mention(mention) {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return html`
 | 
			
		||||
			<div style="display: flex; flex-direction: row">
 | 
			
		||||
				<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>
 | 
			
		||||
		return html` <div style="display: flex; flex-direction: row">
 | 
			
		||||
			<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>
 | 
			
		||||
			</div>
 | 
			
		||||
			<div style="display: flex; flex-direction: column">
 | 
			
		||||
				<h3>${mention.name}</h3>
 | 
			
		||||
				<div style="padding-left: 1em">
 | 
			
		||||
					${Object.entries(mention)
 | 
			
		||||
						.filter((x) => x[0] != 'name')
 | 
			
		||||
						.map(
 | 
			
		||||
							(x) =>
 | 
			
		||||
								html`<div>
 | 
			
		||||
									<span style="font-weight: bold">${x[0]}</span>: ${x[1]}
 | 
			
		||||
								</div>`
 | 
			
		||||
						)}
 | 
			
		||||
				</div>
 | 
			
		||||
				<div style="display: flex; flex-direction: column">
 | 
			
		||||
					<h3>${mention.name}</h3>
 | 
			
		||||
					<div style="padding-left: 1em">
 | 
			
		||||
						${Object.entries(mention)
 | 
			
		||||
							.filter(x => x[0] != 'name')
 | 
			
		||||
							.map(x => html`<div><span style="font-weight: bold">${x[0]}</span>: ${x[1]}</div>`)}
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>`;
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_attach_app() {
 | 
			
		||||
@@ -359,12 +397,21 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			return html`
 | 
			
		||||
				<div class="w3-card-4 w3-margin w3-padding">
 | 
			
		||||
					<select id="select" class="w3-select w3-dark-grey">
 | 
			
		||||
						${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>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${attach_selected_app}>Attach</button>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${() => this.apps = null}>Cancel</button>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${attach_selected_app}>
 | 
			
		||||
						Attach
 | 
			
		||||
					</button>
 | 
			
		||||
					<button
 | 
			
		||||
						class="w3-button w3-dark-grey"
 | 
			
		||||
						@click=${() => (this.apps = null)}
 | 
			
		||||
					>
 | 
			
		||||
						Cancel
 | 
			
		||||
					</button>
 | 
			
		||||
				</div>
 | 
			
		||||
				`;
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -374,9 +421,16 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
			self.apps = await tfrpc.rpc.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-dark-grey" @click=${attach_app}>
 | 
			
		||||
				Attach App
 | 
			
		||||
			</button>`;
 | 
			
		||||
		} else {
 | 
			
		||||
			return html`<button class="w3-button w3-dark-grey" @click=${() => this.apps = null}>Discard App</button>`;
 | 
			
		||||
			return html`<button
 | 
			
		||||
				class="w3-button w3-dark-grey"
 | 
			
		||||
				@click=${() => (this.apps = null)}
 | 
			
		||||
			>
 | 
			
		||||
				Discard App
 | 
			
		||||
			</button>`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -435,11 +489,13 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${() => this.set_encrypt(undefined)}>🚮</button>
 | 
			
		||||
			</div>
 | 
			
		||||
			<ul>
 | 
			
		||||
				${draft.encrypt_to.map(x => html`
 | 
			
		||||
				${draft.encrypt_to.map(
 | 
			
		||||
					(x) => html`
 | 
			
		||||
					<li>
 | 
			
		||||
						<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>
 | 
			
		||||
					</li>`)}
 | 
			
		||||
						<input type="button" class="w3-button w3-dark-grey" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter((id) => id != x))}></input>
 | 
			
		||||
					</li>`
 | 
			
		||||
				)}
 | 
			
		||||
			</ul>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
@@ -455,34 +511,65 @@ class TfComposeElement extends LitElement {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		let draft = self.get_draft();
 | 
			
		||||
		let content_warning =
 | 
			
		||||
			draft.content_warning !== undefined ?
 | 
			
		||||
			html`<div class="w3-panel w3-round-xlarge w3-blue">
 | 
			
		||||
				<p id="content_warning_preview">${draft.content_warning}</p>
 | 
			
		||||
			</div>` :
 | 
			
		||||
			undefined;
 | 
			
		||||
		let encrypt = draft.encrypt_to !== undefined ?
 | 
			
		||||
			undefined :
 | 
			
		||||
			html`<button class="w3-button w3-dark-grey" @click=${() => this.set_encrypt([])}>🔐</button>`;
 | 
			
		||||
			draft.content_warning !== undefined
 | 
			
		||||
				? html`<div class="w3-panel w3-round-xlarge w3-blue">
 | 
			
		||||
						<p id="content_warning_preview">${draft.content_warning}</p>
 | 
			
		||||
					</div>`
 | 
			
		||||
				: undefined;
 | 
			
		||||
		let encrypt =
 | 
			
		||||
			draft.encrypt_to !== undefined
 | 
			
		||||
				? undefined
 | 
			
		||||
				: html`<button
 | 
			
		||||
						class="w3-button w3-dark-grey"
 | 
			
		||||
						@click=${() => this.set_encrypt([])}
 | 
			
		||||
					>
 | 
			
		||||
						🔐
 | 
			
		||||
					</button>`;
 | 
			
		||||
		let result = html`
 | 
			
		||||
			<div class="w3-card-4 w3-blue-grey w3-padding" style="box-sizing: border-box">
 | 
			
		||||
			<div
 | 
			
		||||
				class="w3-card-4 w3-blue-grey w3-padding"
 | 
			
		||||
				style="box-sizing: border-box"
 | 
			
		||||
			>
 | 
			
		||||
				${this.render_encrypt()}
 | 
			
		||||
				<div style="display: flex; flex-direction: row; width: 100%; gap: 4px">
 | 
			
		||||
					<div style="flex: 1 0 50%">
 | 
			
		||||
						<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>
 | 
			
		||||
						<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>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div style="flex: 1 0 50%">
 | 
			
		||||
						${content_warning}
 | 
			
		||||
						<div id="preview"></div>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
				${Object.values(draft.mentions || {}).map(x => self.render_mention(x))}
 | 
			
		||||
				${this.render_attach_app()}
 | 
			
		||||
				${this.render_content_warning()}
 | 
			
		||||
				<button class="w3-button w3-dark-grey" id="submit" @click=${this.submit}>Submit</button>
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${this.attach}>Attach</button>
 | 
			
		||||
				${this.render_attach_app_button()}
 | 
			
		||||
				${encrypt}
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${this.discard}>Discard</button>
 | 
			
		||||
				${Object.values(draft.mentions || {}).map((x) =>
 | 
			
		||||
					self.render_mention(x)
 | 
			
		||||
				)}
 | 
			
		||||
				${this.render_attach_app()} ${this.render_content_warning()}
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-dark-grey"
 | 
			
		||||
					id="submit"
 | 
			
		||||
					@click=${this.submit}
 | 
			
		||||
				>
 | 
			
		||||
					Submit
 | 
			
		||||
				</button>
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${this.attach}>
 | 
			
		||||
					Attach
 | 
			
		||||
				</button>
 | 
			
		||||
				${this.render_attach_app_button()} ${encrypt}
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${this.discard}>
 | 
			
		||||
					Discard
 | 
			
		||||
				</button>
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
		return result;
 | 
			
		||||
 
 | 
			
		||||
@@ -3,8 +3,8 @@ 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.
 | 
			
		||||
*/
 | 
			
		||||
 ** Provide a list of IDs, and this lets the user pick one.
 | 
			
		||||
 */
 | 
			
		||||
class TfIdentityPickerElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
@@ -24,15 +24,28 @@ class TfIdentityPickerElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	changed(event) {
 | 
			
		||||
		this.selected = event.srcElement.value;
 | 
			
		||||
		this.dispatchEvent(new Event('change', {
 | 
			
		||||
			srcElement: this,
 | 
			
		||||
		}));
 | 
			
		||||
		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
 | 
			
		||||
				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>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
 
 | 
			
		||||
@@ -31,14 +31,27 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	show_reply() {
 | 
			
		||||
		let event = new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.message?.id, draft: {
 | 
			
		||||
			encrypt_to: this.message?.decrypted?.recps,
 | 
			
		||||
		}}});
 | 
			
		||||
		let event = new CustomEvent('tf-draft', {
 | 
			
		||||
			bubbles: true,
 | 
			
		||||
			composed: true,
 | 
			
		||||
			detail: {
 | 
			
		||||
				id: this.message?.id,
 | 
			
		||||
				draft: {
 | 
			
		||||
					encrypt_to: this.message?.decrypted?.recps,
 | 
			
		||||
				},
 | 
			
		||||
			},
 | 
			
		||||
		});
 | 
			
		||||
		this.dispatchEvent(event);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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},
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_votes() {
 | 
			
		||||
@@ -53,12 +66,19 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
				return expression;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		return html`<div>${(this.message.votes || []).map(
 | 
			
		||||
			vote => html`
 | 
			
		||||
				<span title="${this.users[vote.author]?.name ?? vote.author} ${new Date(vote.timestamp)}">
 | 
			
		||||
					${normalize_expression(vote.content.vote.expression)}
 | 
			
		||||
				</span>
 | 
			
		||||
			`)}</div>`;
 | 
			
		||||
		return html`<div>
 | 
			
		||||
			${(this.message.votes || []).map(
 | 
			
		||||
				(vote) => html`
 | 
			
		||||
					<span
 | 
			
		||||
						title="${this.users[vote.author]?.name ?? vote.author} ${new Date(
 | 
			
		||||
							vote.timestamp
 | 
			
		||||
						)}"
 | 
			
		||||
					>
 | 
			
		||||
						${normalize_expression(vote.content.vote.expression)}
 | 
			
		||||
					</span>
 | 
			
		||||
				`
 | 
			
		||||
			)}
 | 
			
		||||
		</div>`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_raw() {
 | 
			
		||||
@@ -72,30 +92,40 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
			content: this.message?.content,
 | 
			
		||||
			signature: this.message?.signature,
 | 
			
		||||
		};
 | 
			
		||||
		return html`<div style="white-space: pre-wrap">${JSON.stringify(raw, null, 2)}</div>`;
 | 
			
		||||
		return html`<div style="white-space: pre-wrap">
 | 
			
		||||
			${JSON.stringify(raw, null, 2)}
 | 
			
		||||
		</div>`;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	vote(emoji) {
 | 
			
		||||
		let reaction = emoji;
 | 
			
		||||
		let message = this.message.id;
 | 
			
		||||
		if (confirm('Are you sure you want to react with ' + reaction + ' to ' + message + '?')) {
 | 
			
		||||
			tfrpc.rpc.appendMessage(
 | 
			
		||||
				this.whoami,
 | 
			
		||||
				{
 | 
			
		||||
		if (
 | 
			
		||||
			confirm(
 | 
			
		||||
				'Are you sure you want to react with ' +
 | 
			
		||||
					reaction +
 | 
			
		||||
					' to ' +
 | 
			
		||||
					message +
 | 
			
		||||
					'?'
 | 
			
		||||
			)
 | 
			
		||||
		) {
 | 
			
		||||
			tfrpc.rpc
 | 
			
		||||
				.appendMessage(this.whoami, {
 | 
			
		||||
					type: 'vote',
 | 
			
		||||
					vote: {
 | 
			
		||||
						link: message,
 | 
			
		||||
						value: 1,
 | 
			
		||||
						expression: reaction,
 | 
			
		||||
					},
 | 
			
		||||
				}).catch(function(error) {
 | 
			
		||||
				})
 | 
			
		||||
				.catch(function (error) {
 | 
			
		||||
					alert(error?.message);
 | 
			
		||||
				});
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	react(event) {
 | 
			
		||||
		emojis.picker(x => this.vote(x));
 | 
			
		||||
		emojis.picker((x) => this.vote(x));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	show_image(link) {
 | 
			
		||||
@@ -129,7 +159,10 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
	body_click(event) {
 | 
			
		||||
		if (event.srcElement.tagName == 'IMG') {
 | 
			
		||||
			this.show_image(event.srcElement.src);
 | 
			
		||||
		} else if (event.srcElement.tagName == 'DIV' && event.srcElement.classList.contains('img_caption')) {
 | 
			
		||||
		} else if (
 | 
			
		||||
			event.srcElement.tagName == 'DIV' &&
 | 
			
		||||
			event.srcElement.classList.contains('img_caption')
 | 
			
		||||
		) {
 | 
			
		||||
			let next = event.srcElement.nextSibling;
 | 
			
		||||
			if (next.style.display == 'block') {
 | 
			
		||||
				next.style.display = 'none';
 | 
			
		||||
@@ -140,50 +173,77 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_mention(mention) {
 | 
			
		||||
		if (!mention?.link || typeof(mention.link) != 'string') {
 | 
			
		||||
		if (!mention?.link || typeof mention.link != 'string') {
 | 
			
		||||
			return html` <pre>${JSON.stringify(mention)}</pre>`;
 | 
			
		||||
		} else if (mention?.link?.startsWith('&') &&
 | 
			
		||||
			mention?.type?.startsWith('image/')) {
 | 
			
		||||
		} else if (
 | 
			
		||||
			mention?.link?.startsWith('&') &&
 | 
			
		||||
			mention?.type?.startsWith('image/')
 | 
			
		||||
		) {
 | 
			
		||||
			return html`
 | 
			
		||||
				<img src=${'/' + mention.link + '/view'} style="max-width: 128px; max-height: 128px" title=${mention.name} @click=${() => this.show_image('/' + mention.link + '/view')}>
 | 
			
		||||
				<img
 | 
			
		||||
					src=${'/' + mention.link + '/view'}
 | 
			
		||||
					style="max-width: 128px; max-height: 128px"
 | 
			
		||||
					title=${mention.name}
 | 
			
		||||
					@click=${() => this.show_image('/' + mention.link + '/view')}
 | 
			
		||||
				/>
 | 
			
		||||
			`;
 | 
			
		||||
		} else if (mention.link?.startsWith('&') &&
 | 
			
		||||
			mention.name?.startsWith('audio:')) {
 | 
			
		||||
		} else if (
 | 
			
		||||
			mention.link?.startsWith('&') &&
 | 
			
		||||
			mention.name?.startsWith('audio:')
 | 
			
		||||
		) {
 | 
			
		||||
			return html`
 | 
			
		||||
				<audio controls style="height: 32px">
 | 
			
		||||
					<source src=${'/' + mention.link + '/view'}></source>
 | 
			
		||||
				</audio>
 | 
			
		||||
			`;
 | 
			
		||||
		} else if (mention.link?.startsWith('&') &&
 | 
			
		||||
			mention.name?.startsWith('video:')) {
 | 
			
		||||
		} else if (
 | 
			
		||||
			mention.link?.startsWith('&') &&
 | 
			
		||||
			mention.name?.startsWith('video:')
 | 
			
		||||
		) {
 | 
			
		||||
			return html`
 | 
			
		||||
				<video controls style="max-height: 240px; max-width: 128px">
 | 
			
		||||
					<source src=${'/' + mention.link + '/view'}></source>
 | 
			
		||||
				</video>
 | 
			
		||||
			`;
 | 
			
		||||
		} else if (mention.link?.startsWith('&') &&
 | 
			
		||||
			mention?.type === 'application/tildefriends') {
 | 
			
		||||
		} else if (
 | 
			
		||||
			mention.link?.startsWith('&') &&
 | 
			
		||||
			mention?.type === 'application/tildefriends'
 | 
			
		||||
		) {
 | 
			
		||||
			return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`;
 | 
			
		||||
		} else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) {
 | 
			
		||||
			return html` <a href=${'#' + encodeURIComponent(mention.link)}>${mention.name}</a>`;
 | 
			
		||||
			return html` <a href=${'#' + encodeURIComponent(mention.link)}
 | 
			
		||||
				>${mention.name}</a
 | 
			
		||||
			>`;
 | 
			
		||||
		} else if (mention.link?.startsWith('#')) {
 | 
			
		||||
			return html` <a href=${'#q=' + encodeURIComponent(mention.link)}>${mention.link}</a>`;
 | 
			
		||||
		} else if (Object.keys(mention).length == 2 && mention.link && mention.name) {
 | 
			
		||||
			return html` <a href=${'#q=' + encodeURIComponent(mention.link)}
 | 
			
		||||
				>${mention.link}</a
 | 
			
		||||
			>`;
 | 
			
		||||
		} else if (
 | 
			
		||||
			Object.keys(mention).length == 2 &&
 | 
			
		||||
			mention.link &&
 | 
			
		||||
			mention.name
 | 
			
		||||
		) {
 | 
			
		||||
			return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`;
 | 
			
		||||
		} else {
 | 
			
		||||
			return html` <pre style="white-space: pre-wrap">${JSON.stringify(mention, null, 2)}</pre>`;
 | 
			
		||||
			return html` <pre style="white-space: pre-wrap">
 | 
			
		||||
${JSON.stringify(mention, null, 2)}</pre
 | 
			
		||||
			>`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_mentions() {
 | 
			
		||||
		let mentions = this.message?.content?.mentions || [];
 | 
			
		||||
		mentions = mentions.filter(x => this.message?.content?.text?.indexOf(x.link) === -1);
 | 
			
		||||
		mentions = mentions.filter(
 | 
			
		||||
			(x) => this.message?.content?.text?.indexOf(x.link) === -1
 | 
			
		||||
		);
 | 
			
		||||
		if (mentions.length) {
 | 
			
		||||
			let self = this;
 | 
			
		||||
			return html`
 | 
			
		||||
				<fieldset style="background-color: rgba(0, 0, 0, 0.1); padding: 0.5em; border: 1px solid black">
 | 
			
		||||
				<fieldset
 | 
			
		||||
					style="background-color: rgba(0, 0, 0, 0.1); padding: 0.5em; border: 1px solid black"
 | 
			
		||||
				>
 | 
			
		||||
					<legend>Mentions</legend>
 | 
			
		||||
					${mentions.map(x => self.render_mention(x))}
 | 
			
		||||
					${mentions.map((x) => self.render_mention(x))}
 | 
			
		||||
				</fieldset>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
@@ -194,28 +254,55 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
			return 0;
 | 
			
		||||
		}
 | 
			
		||||
		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);
 | 
			
		||||
		}
 | 
			
		||||
		return total;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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) {
 | 
			
		||||
		this.set_expanded(!this.expanded[(this.message.id || '') + (tag || '')], tag);
 | 
			
		||||
		this.set_expanded(
 | 
			
		||||
			!this.expanded[(this.message.id || '') + (tag || '')],
 | 
			
		||||
			tag
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render_children() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		if (this.message.child_messages?.length) {
 | 
			
		||||
			if (!this.expanded[this.message.id]) {
 | 
			
		||||
				return html`<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-dark-grey"
 | 
			
		||||
					@click=${() => self.set_expanded(true)}
 | 
			
		||||
				>
 | 
			
		||||
					+ ${this.total_child_messages(this.message) + ' More'}
 | 
			
		||||
				</button>`;
 | 
			
		||||
			} 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-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>`
 | 
			
		||||
					)}`;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -231,13 +318,12 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
		}
 | 
			
		||||
		if (Array.isArray(content.mentions)) {
 | 
			
		||||
			for (let mention of content.mentions) {
 | 
			
		||||
				if (typeof mention?.link === 'string' &&
 | 
			
		||||
					mention.link.startsWith('#')) {
 | 
			
		||||
				if (typeof mention?.link === 'string' && mention.link.startsWith('#')) {
 | 
			
		||||
					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() {
 | 
			
		||||
@@ -250,54 +336,110 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
		switch (this.format) {
 | 
			
		||||
			case 'raw':
 | 
			
		||||
				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-dark-grey"
 | 
			
		||||
						@click=${() => (self.format = 'md')}
 | 
			
		||||
					>
 | 
			
		||||
						Markdown
 | 
			
		||||
					</button>`;
 | 
			
		||||
				} 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-dark-grey"
 | 
			
		||||
						@click=${() => (self.format = 'message')}
 | 
			
		||||
					>
 | 
			
		||||
						Message
 | 
			
		||||
					</button>`;
 | 
			
		||||
				}
 | 
			
		||||
				break;
 | 
			
		||||
			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-dark-grey"
 | 
			
		||||
					@click=${() => (self.format = 'message')}
 | 
			
		||||
				>
 | 
			
		||||
					Message
 | 
			
		||||
				</button>`;
 | 
			
		||||
				break;
 | 
			
		||||
			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-dark-grey"
 | 
			
		||||
					@click=${() => (self.format = 'raw')}
 | 
			
		||||
				>
 | 
			
		||||
					Raw
 | 
			
		||||
				</button>`;
 | 
			
		||||
				break;
 | 
			
		||||
			default:
 | 
			
		||||
				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-dark-grey"
 | 
			
		||||
						@click=${() => (self.format = 'decrypted')}
 | 
			
		||||
					>
 | 
			
		||||
						Decrypted
 | 
			
		||||
					</button>`;
 | 
			
		||||
				} 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-dark-grey"
 | 
			
		||||
						@click=${() => (self.format = 'raw')}
 | 
			
		||||
					>
 | 
			
		||||
						Raw
 | 
			
		||||
					</button>`;
 | 
			
		||||
				}
 | 
			
		||||
				break;
 | 
			
		||||
		}
 | 
			
		||||
		function small_frame(inner) {
 | 
			
		||||
			let body;
 | 
			
		||||
			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"
 | 
			
		||||
					style="background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px; display: inline-block; overflow-wrap: anywhere"
 | 
			
		||||
				>
 | 
			
		||||
					<tf-user id=${self.message.author} .users=${self.users}></tf-user>
 | 
			
		||||
					<span style="padding-right: 8px"><a tfarget="_top" href=${'#' + self.message.id}>%</a> ${new Date(self.message.timestamp).toLocaleString()}</span>
 | 
			
		||||
					${raw_button}
 | 
			
		||||
					${self.format == 'raw' ? self.render_raw() : inner}
 | 
			
		||||
					<span style="padding-right: 8px"
 | 
			
		||||
						><a tfarget="_top" href=${'#' + self.message.id}>%</a> ${new Date(
 | 
			
		||||
							self.message.timestamp
 | 
			
		||||
						).toLocaleString()}</span
 | 
			
		||||
					>
 | 
			
		||||
					${raw_button} ${self.format == 'raw' ? self.render_raw() : inner}
 | 
			
		||||
					${self.render_votes()}
 | 
			
		||||
				</div>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
		if (this.message?.type === 'contact_group') {
 | 
			
		||||
			return html`
 | 
			
		||||
				<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">
 | 
			
		||||
					${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>`;
 | 
			
		||||
			return html` <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"
 | 
			
		||||
			>
 | 
			
		||||
				${this.message.messages.map(
 | 
			
		||||
					(x) =>
 | 
			
		||||
						html`<tf-message
 | 
			
		||||
							.message=${x}
 | 
			
		||||
							whoami=${this.whoami}
 | 
			
		||||
							.users=${this.users}
 | 
			
		||||
							.drafts=${this.drafts}
 | 
			
		||||
							.expanded=${this.expanded}
 | 
			
		||||
						></tf-message>`
 | 
			
		||||
				)}
 | 
			
		||||
			</div>`;
 | 
			
		||||
		} else if (this.message.placeholder) {
 | 
			
		||||
			return html`
 | 
			
		||||
				<div 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">
 | 
			
		||||
					<a target="_top" href=${'#' + this.message.id}>${this.message.id}</a> (placeholder)
 | 
			
		||||
					<div>${this.render_votes()}</div>
 | 
			
		||||
					${(this.message.child_messages || []).map(x => html`
 | 
			
		||||
						<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>
 | 
			
		||||
					`)}
 | 
			
		||||
				</div>`;
 | 
			
		||||
		} else if (typeof(content?.type === 'string')) {
 | 
			
		||||
			return html` <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"
 | 
			
		||||
			>
 | 
			
		||||
				<a target="_top" href=${'#' + this.message.id}>${this.message.id}</a>
 | 
			
		||||
				(placeholder)
 | 
			
		||||
				<div>${this.render_votes()}</div>
 | 
			
		||||
				${(this.message.child_messages || []).map(
 | 
			
		||||
					(x) => html`
 | 
			
		||||
						<tf-message
 | 
			
		||||
							.message=${x}
 | 
			
		||||
							whoami=${this.whoami}
 | 
			
		||||
							.users=${this.users}
 | 
			
		||||
							.drafts=${this.drafts}
 | 
			
		||||
							.expanded=${this.expanded}
 | 
			
		||||
						></tf-message>
 | 
			
		||||
					`
 | 
			
		||||
				)}
 | 
			
		||||
			</div>`;
 | 
			
		||||
		} else if (typeof (content?.type === 'string')) {
 | 
			
		||||
			if (content.type == 'about') {
 | 
			
		||||
				let name;
 | 
			
		||||
				let image;
 | 
			
		||||
@@ -307,7 +449,7 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
				}
 | 
			
		||||
				if (content.image !== undefined) {
 | 
			
		||||
					image = html`
 | 
			
		||||
						<div><img src=${'/' + (typeof(content.image?.link) == 'string' ? content.image.link : content.image) + '/view'} style="width: 256px; height: auto"></img></div>
 | 
			
		||||
						<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) {
 | 
			
		||||
@@ -317,42 +459,55 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
						</div>
 | 
			
		||||
					`;
 | 
			
		||||
				}
 | 
			
		||||
				let update = content.about == this.message.author ?
 | 
			
		||||
					html`<div style="font-weight: bold">Updated profile.</div>` :
 | 
			
		||||
					html`<div style="font-weight: bold">Updated profile for <tf-user id=${content.about} .users=${this.users}></tf-user>.</div>`;
 | 
			
		||||
				return small_frame(html`
 | 
			
		||||
					${update}
 | 
			
		||||
					${name}
 | 
			
		||||
					${image}
 | 
			
		||||
					${description}
 | 
			
		||||
				`);
 | 
			
		||||
				let update =
 | 
			
		||||
					content.about == this.message.author
 | 
			
		||||
						? html`<div style="font-weight: bold">Updated profile.</div>`
 | 
			
		||||
						: html`<div style="font-weight: bold">
 | 
			
		||||
								Updated profile for
 | 
			
		||||
								<tf-user id=${content.about} .users=${this.users}></tf-user>.
 | 
			
		||||
							</div>`;
 | 
			
		||||
				return small_frame(html` ${update} ${name} ${image} ${description} `);
 | 
			
		||||
			} else if (content.type == 'contact') {
 | 
			
		||||
				return html`
 | 
			
		||||
					<div>
 | 
			
		||||
						<tf-user id=${this.message.author} .users=${this.users}></tf-user>
 | 
			
		||||
						is
 | 
			
		||||
						${
 | 
			
		||||
							content.blocking === true ? 'blocking' :
 | 
			
		||||
							content.blocking === false ? 'no longer blocking' :
 | 
			
		||||
							content.following === true ? 'following' :
 | 
			
		||||
							content.following === false ? 'no longer following' :
 | 
			
		||||
							'?'
 | 
			
		||||
						}
 | 
			
		||||
						<tf-user id=${this.message.content.contact} .users=${this.users}></tf-user>
 | 
			
		||||
						${content.blocking === true
 | 
			
		||||
							? 'blocking'
 | 
			
		||||
							: content.blocking === false
 | 
			
		||||
								? 'no longer blocking'
 | 
			
		||||
								: content.following === true
 | 
			
		||||
									? 'following'
 | 
			
		||||
									: content.following === false
 | 
			
		||||
										? 'no longer following'
 | 
			
		||||
										: '?'}
 | 
			
		||||
						<tf-user
 | 
			
		||||
							id=${this.message.content.contact}
 | 
			
		||||
							.users=${this.users}
 | 
			
		||||
						></tf-user>
 | 
			
		||||
					</div>
 | 
			
		||||
				`;
 | 
			
		||||
			} else if (content.type == 'post') {
 | 
			
		||||
				let reply = (this.drafts[this.message?.id] !== undefined) ? html`
 | 
			
		||||
					<tf-compose
 | 
			
		||||
						whoami=${this.whoami}
 | 
			
		||||
						.users=${this.users}
 | 
			
		||||
						root=${this.message.content.root || this.message.id}
 | 
			
		||||
						branch=${this.message.id}
 | 
			
		||||
						.drafts=${this.drafts}
 | 
			
		||||
						@tf-discard=${this.discard_reply}></tf-compose>
 | 
			
		||||
				` : html`
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.show_reply}>Reply</button>
 | 
			
		||||
				`;
 | 
			
		||||
				let reply =
 | 
			
		||||
					this.drafts[this.message?.id] !== undefined
 | 
			
		||||
						? html`
 | 
			
		||||
								<tf-compose
 | 
			
		||||
									whoami=${this.whoami}
 | 
			
		||||
									.users=${this.users}
 | 
			
		||||
									root=${content.root || this.message.id}
 | 
			
		||||
									branch=${this.message.id}
 | 
			
		||||
									.drafts=${this.drafts}
 | 
			
		||||
									@tf-discard=${this.discard_reply}
 | 
			
		||||
								></tf-compose>
 | 
			
		||||
							`
 | 
			
		||||
						: html`
 | 
			
		||||
								<button
 | 
			
		||||
									class="w3-button w3-dark-grey"
 | 
			
		||||
									@click=${this.show_reply}
 | 
			
		||||
								>
 | 
			
		||||
									Reply
 | 
			
		||||
								</button>
 | 
			
		||||
							`;
 | 
			
		||||
				let self = this;
 | 
			
		||||
				let body;
 | 
			
		||||
				switch (this.format) {
 | 
			
		||||
@@ -360,35 +515,47 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
						body = this.render_raw();
 | 
			
		||||
						break;
 | 
			
		||||
					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;
 | 
			
		||||
					case 'message':
 | 
			
		||||
						body = unsafeHTML(tfutils.markdown(content.text));
 | 
			
		||||
						break;
 | 
			
		||||
					case 'decrypted':
 | 
			
		||||
						body = html`<pre style="white-space: pre-wrap; overflow-wrap: anywhere">${JSON.stringify(content, null, 2)}</pre>`;
 | 
			
		||||
						body = html`<pre
 | 
			
		||||
							style="white-space: pre-wrap; overflow-wrap: anywhere"
 | 
			
		||||
						>
 | 
			
		||||
${JSON.stringify(content, null, 2)}</pre
 | 
			
		||||
						>`;
 | 
			
		||||
						break;
 | 
			
		||||
				}
 | 
			
		||||
				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>
 | 
			
		||||
					`;
 | 
			
		||||
				let content_html =
 | 
			
		||||
					html`
 | 
			
		||||
						${this.render_channels()}
 | 
			
		||||
						<div @click=${this.body_click}>${body}</div>
 | 
			
		||||
						${this.render_mentions()}
 | 
			
		||||
					`;
 | 
			
		||||
				let payload =
 | 
			
		||||
					content.contentWarning ?
 | 
			
		||||
						self.expanded[(this.message.id || '') + ':cw'] ?
 | 
			
		||||
							html`
 | 
			
		||||
								${content_warning}
 | 
			
		||||
								${content_html}
 | 
			
		||||
							` :
 | 
			
		||||
							content_warning :
 | 
			
		||||
						content_html;
 | 
			
		||||
				let is_encrypted = this.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)';
 | 
			
		||||
					<div
 | 
			
		||||
						class="w3-panel w3-round-xlarge w3-blue"
 | 
			
		||||
						style="cursor: pointer"
 | 
			
		||||
						@click=${(x) => this.toggle_expanded(':cw')}
 | 
			
		||||
					>
 | 
			
		||||
						<p>${content.contentWarning}</p>
 | 
			
		||||
					</div>
 | 
			
		||||
				`;
 | 
			
		||||
				let content_html = html`
 | 
			
		||||
					${this.render_channels()}
 | 
			
		||||
					<div @click=${this.body_click}>${body}</div>
 | 
			
		||||
					${this.render_mentions()}
 | 
			
		||||
				`;
 | 
			
		||||
				let payload = content.contentWarning
 | 
			
		||||
					? self.expanded[(this.message.id || '') + ':cw']
 | 
			
		||||
						? html` ${content_warning} ${content_html} `
 | 
			
		||||
						: content_warning
 | 
			
		||||
					: content_html;
 | 
			
		||||
				let is_encrypted = this.message?.decrypted
 | 
			
		||||
					? html`<span style="align-self: center">🔓</span>`
 | 
			
		||||
					: undefined;
 | 
			
		||||
				let style_background = this.message?.decrypted
 | 
			
		||||
					? 'rgba(255, 0, 0, 0.2)'
 | 
			
		||||
					: 'rgba(255, 255, 255, 0.1)';
 | 
			
		||||
				return html`
 | 
			
		||||
					<style>
 | 
			
		||||
						code {
 | 
			
		||||
@@ -404,26 +571,37 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
							display: block;
 | 
			
		||||
						}
 | 
			
		||||
					</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"
 | 
			
		||||
						style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px"
 | 
			
		||||
					>
 | 
			
		||||
						<div style="display: flex; flex-direction: row">
 | 
			
		||||
							<tf-user id=${this.message.author} .users=${this.users}></tf-user>
 | 
			
		||||
							${is_encrypted}
 | 
			
		||||
							<span style="flex: 1"></span>
 | 
			
		||||
							<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span>
 | 
			
		||||
							<span style="padding-right: 8px"
 | 
			
		||||
								><a target="_top" href=${'#' + self.message.id}>%</a>
 | 
			
		||||
								${new Date(this.message.timestamp).toLocaleString()}</span
 | 
			
		||||
							>
 | 
			
		||||
							<span>${raw_button}</span>
 | 
			
		||||
						</div>
 | 
			
		||||
						${payload}
 | 
			
		||||
						${this.render_votes()}
 | 
			
		||||
						${payload} ${this.render_votes()}
 | 
			
		||||
						<p>
 | 
			
		||||
							${reply}
 | 
			
		||||
							<button class="w3-button w3-dark-grey" @click=${this.react}>React</button>
 | 
			
		||||
							<button class="w3-button w3-dark-grey" @click=${this.react}>
 | 
			
		||||
								React
 | 
			
		||||
							</button>
 | 
			
		||||
						</p>
 | 
			
		||||
						${this.render_children()}
 | 
			
		||||
					</div>
 | 
			
		||||
				`;
 | 
			
		||||
			} else if (content.type === 'issue') {
 | 
			
		||||
				let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined;
 | 
			
		||||
				let style_background = this.message?.decrypted ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)';
 | 
			
		||||
				let is_encrypted = this.message?.decrypted
 | 
			
		||||
					? html`<span style="align-self: center">🔓</span>`
 | 
			
		||||
					: undefined;
 | 
			
		||||
				let style_background = this.message?.decrypted
 | 
			
		||||
					? 'rgba(255, 0, 0, 0.2)'
 | 
			
		||||
					: 'rgba(255, 255, 255, 0.1)';
 | 
			
		||||
				return html`
 | 
			
		||||
					<style>
 | 
			
		||||
						code {
 | 
			
		||||
@@ -439,31 +617,41 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
							display: block;
 | 
			
		||||
						}
 | 
			
		||||
					</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"
 | 
			
		||||
						style="border: 1px solid black; background-color: ${style_background}; margin-top: 8px; padding: 16px"
 | 
			
		||||
					>
 | 
			
		||||
						<div style="display: flex; flex-direction: row">
 | 
			
		||||
							<tf-user id=${this.message.author} .users=${this.users}></tf-user>
 | 
			
		||||
							${is_encrypted}
 | 
			
		||||
							<span style="flex: 1"></span>
 | 
			
		||||
							<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span>
 | 
			
		||||
							<span style="padding-right: 8px"
 | 
			
		||||
								><a target="_top" href=${'#' + self.message.id}>%</a>
 | 
			
		||||
								${new Date(this.message.timestamp).toLocaleString()}</span
 | 
			
		||||
							>
 | 
			
		||||
							<span>${raw_button}</span>
 | 
			
		||||
						</div>
 | 
			
		||||
						${content.text}
 | 
			
		||||
						${this.render_votes()}
 | 
			
		||||
						${content.text} ${this.render_votes()}
 | 
			
		||||
						<p>
 | 
			
		||||
							<button class="w3-button w3-dark-grey" @click=${this.react}>React</button>
 | 
			
		||||
							<button class="w3-button w3-dark-grey" @click=${this.react}>
 | 
			
		||||
								React
 | 
			
		||||
							</button>
 | 
			
		||||
						</p>
 | 
			
		||||
						${this.render_children()}
 | 
			
		||||
					</div>
 | 
			
		||||
				`;
 | 
			
		||||
			} else if (content.type === 'blog') {
 | 
			
		||||
				let self = this;
 | 
			
		||||
				tfrpc.rpc.get_blob(content.blog).then(function(data) {
 | 
			
		||||
				tfrpc.rpc.get_blob(content.blog).then(function (data) {
 | 
			
		||||
					self.blog_data = data;
 | 
			
		||||
				});
 | 
			
		||||
				let payload =
 | 
			
		||||
						this.expanded[(this.message.id || '') + ':blog'] ?
 | 
			
		||||
						html`<div>${this.blog_data ? unsafeHTML(tfutils.markdown(this.blog_data)) : 'Loading...'}</div>` :
 | 
			
		||||
						undefined;
 | 
			
		||||
				let payload = this.expanded[(this.message.id || '') + ':blog']
 | 
			
		||||
					? html`<div>
 | 
			
		||||
							${this.blog_data
 | 
			
		||||
								? unsafeHTML(tfutils.markdown(this.blog_data))
 | 
			
		||||
								: 'Loading...'}
 | 
			
		||||
						</div>`
 | 
			
		||||
					: undefined;
 | 
			
		||||
				let body;
 | 
			
		||||
				switch (this.format) {
 | 
			
		||||
					case 'raw':
 | 
			
		||||
@@ -476,7 +664,7 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
						body = html`
 | 
			
		||||
							<div
 | 
			
		||||
								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>
 | 
			
		||||
								<div style="display: flex; flex-direction: row">
 | 
			
		||||
									<img src=/${content.thumbnail}/view></img>
 | 
			
		||||
@@ -487,17 +675,26 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
						`;
 | 
			
		||||
						break;
 | 
			
		||||
				}
 | 
			
		||||
				let reply = (this.drafts[this.message?.id] !== undefined) ? html`
 | 
			
		||||
					<tf-compose
 | 
			
		||||
						whoami=${this.whoami}
 | 
			
		||||
						.users=${this.users}
 | 
			
		||||
						root=${this.message.content.root || this.message.id}
 | 
			
		||||
						branch=${this.message.id}
 | 
			
		||||
						.drafts=${this.drafts}
 | 
			
		||||
						@tf-discard=${this.discard_reply}></tf-compose>
 | 
			
		||||
				` : html`
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.show_reply}>Reply</button>
 | 
			
		||||
				`;
 | 
			
		||||
				let reply =
 | 
			
		||||
					this.drafts[this.message?.id] !== undefined
 | 
			
		||||
						? html`
 | 
			
		||||
								<tf-compose
 | 
			
		||||
									whoami=${this.whoami}
 | 
			
		||||
									.users=${this.users}
 | 
			
		||||
									root=${content.root || this.message.id}
 | 
			
		||||
									branch=${this.message.id}
 | 
			
		||||
									.drafts=${this.drafts}
 | 
			
		||||
									@tf-discard=${this.discard_reply}
 | 
			
		||||
								></tf-compose>
 | 
			
		||||
							`
 | 
			
		||||
						: html`
 | 
			
		||||
								<button
 | 
			
		||||
									class="w3-button w3-dark-grey"
 | 
			
		||||
									@click=${this.show_reply}
 | 
			
		||||
								>
 | 
			
		||||
									Reply
 | 
			
		||||
								</button>
 | 
			
		||||
							`;
 | 
			
		||||
				return html`
 | 
			
		||||
					<style>
 | 
			
		||||
						code {
 | 
			
		||||
@@ -513,11 +710,17 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
							display: block;
 | 
			
		||||
						}
 | 
			
		||||
					</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"
 | 
			
		||||
						style="border: 1px solid black; background-color: rgba(255, 255, 255, 0.1); margin-top: 8px; padding: 16px"
 | 
			
		||||
					>
 | 
			
		||||
						<div style="display: flex; flex-direction: row">
 | 
			
		||||
							<tf-user id=${this.message.author} .users=${this.users}></tf-user>
 | 
			
		||||
							<span style="flex: 1"></span>
 | 
			
		||||
							<span style="padding-right: 8px"><a target="_top" href=${'#' + self.message.id}>%</a> ${new Date(this.message.timestamp).toLocaleString()}</span>
 | 
			
		||||
							<span style="padding-right: 8px"
 | 
			
		||||
								><a target="_top" href=${'#' + self.message.id}>%</a>
 | 
			
		||||
								${new Date(this.message.timestamp).toLocaleString()}</span
 | 
			
		||||
							>
 | 
			
		||||
							<span>${raw_button}</span>
 | 
			
		||||
						</div>
 | 
			
		||||
 | 
			
		||||
@@ -525,37 +728,52 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
						${this.render_mentions()}
 | 
			
		||||
						<div>
 | 
			
		||||
							${reply}
 | 
			
		||||
							<button class="w3-button w3-dark-grey" @click=${this.react}>React</button>
 | 
			
		||||
							<button class="w3-button w3-dark-grey" @click=${this.react}>
 | 
			
		||||
								React
 | 
			
		||||
							</button>
 | 
			
		||||
						</div>
 | 
			
		||||
						${this.render_votes()}
 | 
			
		||||
						${this.render_children()}
 | 
			
		||||
						${this.render_votes()} ${this.render_children()}
 | 
			
		||||
					</div>
 | 
			
		||||
				`;
 | 
			
		||||
			} else if (content.type === 'pub') {
 | 
			
		||||
				return small_frame(html`
 | 
			
		||||
				<style>
 | 
			
		||||
					span {
 | 
			
		||||
						overflow-wrap: anywhere;
 | 
			
		||||
					}
 | 
			
		||||
				</style>
 | 
			
		||||
				<span>
 | 
			
		||||
					<div>
 | 
			
		||||
						🍻 <tf-user .users=${this.users} id=${content.address.key}></tf-user>
 | 
			
		||||
					</div>
 | 
			
		||||
					<pre>${content.address.host}:${content.address.port}</pre>
 | 
			
		||||
				</span>`);
 | 
			
		||||
				return small_frame(
 | 
			
		||||
					html` <style>
 | 
			
		||||
							span {
 | 
			
		||||
								overflow-wrap: anywhere;
 | 
			
		||||
							}
 | 
			
		||||
						</style>
 | 
			
		||||
						<span>
 | 
			
		||||
							<div>
 | 
			
		||||
								🍻
 | 
			
		||||
								<tf-user
 | 
			
		||||
									.users=${this.users}
 | 
			
		||||
									id=${content.address.key}
 | 
			
		||||
								></tf-user>
 | 
			
		||||
							</div>
 | 
			
		||||
							<pre>${content.address.host}:${content.address.port}</pre>
 | 
			
		||||
						</span>`
 | 
			
		||||
				);
 | 
			
		||||
			} else if (content.type === 'channel') {
 | 
			
		||||
				return small_frame(html`
 | 
			
		||||
					<div>
 | 
			
		||||
						${content.subscribed ? 'subscribed to' : 'unsubscribed from'} <a href=${'#q=' + encodeURIComponent('#' + content.channel)}>#${content.channel}</a>
 | 
			
		||||
						${content.subscribed ? 'subscribed to' : 'unsubscribed from'}
 | 
			
		||||
						<a href=${'#q=' + encodeURIComponent('#' + content.channel)}
 | 
			
		||||
							>#${content.channel}</a
 | 
			
		||||
						>
 | 
			
		||||
					</div>
 | 
			
		||||
				`);
 | 
			
		||||
			} else if (typeof(this.message.content) == 'string') {
 | 
			
		||||
			} else if (typeof this.message.content == 'string') {
 | 
			
		||||
				if (this.message?.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 {
 | 
			
		||||
						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 {
 | 
			
		||||
					return small_frame(html`<span>🔒</span>`);
 | 
			
		||||
@@ -569,4 +787,4 @@ class TfMessageElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-message', TfMessageElement);
 | 
			
		||||
customElements.define('tf-message', TfMessageElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -61,7 +61,7 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
				message.parent_message = message.content.vote.link;
 | 
			
		||||
			} else if (message.content.type == 'post') {
 | 
			
		||||
				if (message.content.root) {
 | 
			
		||||
					if (typeof(message.content.root) === 'string') {
 | 
			
		||||
					if (typeof message.content.root === 'string') {
 | 
			
		||||
						let m = ensure_message(message.content.root);
 | 
			
		||||
						if (!m.child_messages) {
 | 
			
		||||
							m.child_messages = [];
 | 
			
		||||
@@ -89,8 +89,7 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
		for (let message of messages) {
 | 
			
		||||
			try {
 | 
			
		||||
				message.content = JSON.parse(message.content);
 | 
			
		||||
			} catch {
 | 
			
		||||
			}
 | 
			
		||||
			} catch {}
 | 
			
		||||
			if (!messages_by_id[message.id]) {
 | 
			
		||||
				messages_by_id[message.id] = message;
 | 
			
		||||
				link_message(message);
 | 
			
		||||
@@ -100,8 +99,12 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
				message.parent_message = placeholder.parent_message;
 | 
			
		||||
				message.child_messages = placeholder.child_messages;
 | 
			
		||||
				message.votes = placeholder.votes;
 | 
			
		||||
				if (placeholder.parent_message && messages_by_id[placeholder.parent_message]) {
 | 
			
		||||
					let children = messages_by_id[placeholder.parent_message].child_messages;
 | 
			
		||||
				if (
 | 
			
		||||
					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.push(message);
 | 
			
		||||
				}
 | 
			
		||||
@@ -116,7 +119,10 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
		let latest = 0;
 | 
			
		||||
		for (let message of messages || []) {
 | 
			
		||||
			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);
 | 
			
		||||
		}
 | 
			
		||||
@@ -127,20 +133,22 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
		function recursive_sort(messages, top) {
 | 
			
		||||
			if (messages) {
 | 
			
		||||
				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 {
 | 
			
		||||
					messages.sort((a, b) => a.timestamp - b.timestamp);
 | 
			
		||||
				}
 | 
			
		||||
				for (let message of messages) {
 | 
			
		||||
					recursive_sort(message.child_messages, false);
 | 
			
		||||
				}
 | 
			
		||||
				return messages.map(x => Object.assign({}, x));
 | 
			
		||||
				return messages.map((x) => Object.assign({}, x));
 | 
			
		||||
			} else {
 | 
			
		||||
				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);
 | 
			
		||||
		return recursive_sort(roots, true);
 | 
			
		||||
	}
 | 
			
		||||
@@ -167,10 +175,22 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	load_and_render(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`
 | 
			
		||||
			<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>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
@@ -180,4 +200,4 @@ class TfNewsElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-news', TfNewsElement);
 | 
			
		||||
customElements.define('tf-news', TfNewsElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -36,23 +36,29 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
			this.following = undefined;
 | 
			
		||||
			this.blocking = undefined;
 | 
			
		||||
 | 
			
		||||
			let result = await tfrpc.rpc.query(`
 | 
			
		||||
			let result = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
				SELECT json_extract(content, '$.following') AS following
 | 
			
		||||
				FROM messages WHERE author = ? AND
 | 
			
		||||
				json_extract(content, '$.type') = 'contact' AND
 | 
			
		||||
				json_extract(content, '$.contact') = ? AND
 | 
			
		||||
				following IS NOT NULL
 | 
			
		||||
				ORDER BY sequence DESC LIMIT 1
 | 
			
		||||
			`, [this.whoami, this.id]);
 | 
			
		||||
			`,
 | 
			
		||||
				[this.whoami, this.id]
 | 
			
		||||
			);
 | 
			
		||||
			this.following = result?.[0]?.following ?? false;
 | 
			
		||||
			result = await tfrpc.rpc.query(`
 | 
			
		||||
			result = await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
				SELECT json_extract(content, '$.blocking') AS blocking
 | 
			
		||||
				FROM messages WHERE author = ? AND
 | 
			
		||||
				json_extract(content, '$.type') = 'contact' AND
 | 
			
		||||
				json_extract(content, '$.contact') = ? AND
 | 
			
		||||
				blocking IS NOT NULL
 | 
			
		||||
				ORDER BY sequence DESC LIMIT 1
 | 
			
		||||
			`, [this.whoami, this.id]);
 | 
			
		||||
			`,
 | 
			
		||||
				[this.whoami, this.id]
 | 
			
		||||
			);
 | 
			
		||||
			this.blocking = result?.[0]?.blocking ?? false;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -60,13 +66,16 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
	async initial_load() {
 | 
			
		||||
		this.server_follows_me = undefined;
 | 
			
		||||
		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
 | 
			
		||||
			FROM messages
 | 
			
		||||
			WHERE author = ? AND
 | 
			
		||||
			json_extract(content, '$.type') = 'contact' AND
 | 
			
		||||
			json_extract(content, '$.contact') = ? ORDER BY sequence DESC LIMIT 1
 | 
			
		||||
		`, [server_id, this.whoami]);
 | 
			
		||||
		`,
 | 
			
		||||
			[server_id, this.whoami]
 | 
			
		||||
		);
 | 
			
		||||
		let is_followed = false;
 | 
			
		||||
		for (let row of followed) {
 | 
			
		||||
			is_followed = row.following != 0;
 | 
			
		||||
@@ -75,11 +84,18 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	modify(change) {
 | 
			
		||||
		tfrpc.rpc.appendMessage(this.whoami,
 | 
			
		||||
			Object.assign({
 | 
			
		||||
				type: 'contact',
 | 
			
		||||
				contact: this.id,
 | 
			
		||||
			}, change)).catch(function(error) {
 | 
			
		||||
		tfrpc.rpc
 | 
			
		||||
			.appendMessage(
 | 
			
		||||
				this.whoami,
 | 
			
		||||
				Object.assign(
 | 
			
		||||
					{
 | 
			
		||||
						type: 'contact',
 | 
			
		||||
						contact: this.id,
 | 
			
		||||
					},
 | 
			
		||||
					change
 | 
			
		||||
				)
 | 
			
		||||
			)
 | 
			
		||||
			.catch(function (error) {
 | 
			
		||||
				alert(error?.message);
 | 
			
		||||
			});
 | 
			
		||||
	}
 | 
			
		||||
@@ -122,11 +138,14 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
				message[key] = this.editing[key];
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
 | 
			
		||||
			self.editing = null;
 | 
			
		||||
		}).catch(function(error) {
 | 
			
		||||
			alert(error?.message);
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.rpc
 | 
			
		||||
			.appendMessage(this.whoami, message)
 | 
			
		||||
			.then(function () {
 | 
			
		||||
				self.editing = null;
 | 
			
		||||
			})
 | 
			
		||||
			.catch(function (error) {
 | 
			
		||||
				alert(error?.message);
 | 
			
		||||
			});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	discard_edits() {
 | 
			
		||||
@@ -137,17 +156,21 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		let input = document.createElement('input');
 | 
			
		||||
		input.type = 'file';
 | 
			
		||||
		input.onchange = function(event) {
 | 
			
		||||
		input.onchange = function (event) {
 | 
			
		||||
			let file = event.target.files[0];
 | 
			
		||||
			file.arrayBuffer().then(function(buffer) {
 | 
			
		||||
				let bin = Array.from(new Uint8Array(buffer));
 | 
			
		||||
				return tfrpc.rpc.store_blob(bin);
 | 
			
		||||
			}).then(function(id) {
 | 
			
		||||
				self.editing = Object.assign({}, self.editing, {image: id});
 | 
			
		||||
				console.log(self.editing);
 | 
			
		||||
			}).catch(function(e) {
 | 
			
		||||
				alert(e.message);
 | 
			
		||||
			});
 | 
			
		||||
			file
 | 
			
		||||
				.arrayBuffer()
 | 
			
		||||
				.then(function (buffer) {
 | 
			
		||||
					let bin = Array.from(new Uint8Array(buffer));
 | 
			
		||||
					return tfrpc.rpc.store_blob(bin);
 | 
			
		||||
				})
 | 
			
		||||
				.then(function (id) {
 | 
			
		||||
					self.editing = Object.assign({}, self.editing, {image: id});
 | 
			
		||||
					console.log(self.editing);
 | 
			
		||||
				})
 | 
			
		||||
				.catch(function (e) {
 | 
			
		||||
					alert(e.message);
 | 
			
		||||
				});
 | 
			
		||||
		};
 | 
			
		||||
		input.click();
 | 
			
		||||
	}
 | 
			
		||||
@@ -166,15 +189,22 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	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.load();
 | 
			
		||||
		let self = this;
 | 
			
		||||
		let profile = this.users[this.id] || {};
 | 
			
		||||
		tfrpc.rpc.query(
 | 
			
		||||
			`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`,
 | 
			
		||||
			[this.id]).then(function(result) {
 | 
			
		||||
		tfrpc.rpc
 | 
			
		||||
			.query(
 | 
			
		||||
				`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`,
 | 
			
		||||
				[this.id]
 | 
			
		||||
			)
 | 
			
		||||
			.then(function (result) {
 | 
			
		||||
				self.size = result[0].size;
 | 
			
		||||
			});
 | 
			
		||||
		let edit;
 | 
			
		||||
@@ -184,52 +214,75 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
			if (this.editing) {
 | 
			
		||||
				let server_follow;
 | 
			
		||||
				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-dark-grey"
 | 
			
		||||
						@click=${() => this.server_follow_me(false)}
 | 
			
		||||
					>
 | 
			
		||||
						Server, Stop Following Me
 | 
			
		||||
					</button>`;
 | 
			
		||||
				} 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-dark-grey"
 | 
			
		||||
						@click=${() => this.server_follow_me(true)}
 | 
			
		||||
					>
 | 
			
		||||
						Server, Follow Me
 | 
			
		||||
					</button>`;
 | 
			
		||||
				}
 | 
			
		||||
				edit = html`
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.save_edits}>Save Profile</button>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.discard_edits}>Discard</button>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.save_edits}>
 | 
			
		||||
						Save Profile
 | 
			
		||||
					</button>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.discard_edits}>
 | 
			
		||||
						Discard
 | 
			
		||||
					</button>
 | 
			
		||||
					${server_follow}
 | 
			
		||||
				`;
 | 
			
		||||
			} else {
 | 
			
		||||
				edit = html`<button class="w3-button w3-dark-grey" @click=${this.edit}>Edit Profile</button>`;
 | 
			
		||||
				edit = html`<button class="w3-button w3-dark-grey" @click=${this.edit}>
 | 
			
		||||
					Edit Profile
 | 
			
		||||
				</button>`;
 | 
			
		||||
			}
 | 
			
		||||
		}
 | 
			
		||||
		if (this.id !== this.whoami &&
 | 
			
		||||
			this.following !== undefined) {
 | 
			
		||||
			follow =
 | 
			
		||||
				this.following ?
 | 
			
		||||
				html`<button class="w3-button w3-dark-grey" @click=${this.unfollow}>Unfollow</button>` :
 | 
			
		||||
				html`<button class="w3-button w3-dark-grey" @click=${this.follow}>Follow</button>`;
 | 
			
		||||
		if (this.id !== this.whoami && this.following !== undefined) {
 | 
			
		||||
			follow = this.following
 | 
			
		||||
				? html`<button class="w3-button w3-dark-grey" @click=${this.unfollow}>
 | 
			
		||||
						Unfollow
 | 
			
		||||
					</button>`
 | 
			
		||||
				: html`<button class="w3-button w3-dark-grey" @click=${this.follow}>
 | 
			
		||||
						Follow
 | 
			
		||||
					</button>`;
 | 
			
		||||
		}
 | 
			
		||||
		if (this.id !== this.whoami &&
 | 
			
		||||
			this.blocking !== undefined) {
 | 
			
		||||
			block =
 | 
			
		||||
				this.blocking ?
 | 
			
		||||
				html`<button class="w3-button w3-dark-grey" @click=${this.unblock}>Unblock</button>` :
 | 
			
		||||
				html`<button class="w3-button w3-dark-grey" @click=${this.block}>Block</button>`;
 | 
			
		||||
		if (this.id !== this.whoami && this.blocking !== undefined) {
 | 
			
		||||
			block = this.blocking
 | 
			
		||||
				? html`<button class="w3-button w3-dark-grey" @click=${this.unblock}>
 | 
			
		||||
						Unblock
 | 
			
		||||
					</button>`
 | 
			
		||||
				: html`<button class="w3-button w3-dark-grey" @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 class="w3-container">
 | 
			
		||||
					<div>
 | 
			
		||||
						<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-dark-grey" type="text" id="name" value=${this.editing.name} @input=${(event) => (this.editing = Object.assign({}, this.editing, {name: event.srcElement.value}))}></input>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div><label for="description">Description:</label></div>
 | 
			
		||||
					<textarea 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-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>
 | 
			
		||||
					<div>
 | 
			
		||||
						<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-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>
 | 
			
		||||
					</div>
 | 
			
		||||
					<div>
 | 
			
		||||
						<button class="w3-button w3-dark-grey" @click=${this.attach_image}>Attach Image</button>
 | 
			
		||||
					</div>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>` : null;
 | 
			
		||||
		let image = typeof(profile.image) == 'string' ? profile.image : profile.image?.link;
 | 
			
		||||
			</div>`
 | 
			
		||||
			: null;
 | 
			
		||||
		let image =
 | 
			
		||||
			typeof profile.image == 'string' ? profile.image : profile.image?.link;
 | 
			
		||||
		image = this.editing?.image ?? image;
 | 
			
		||||
		let description = this.editing?.description ?? profile.description;
 | 
			
		||||
		return html`<div style="border: 2px solid black; background-color: rgba(255, 255, 255, 0.2); padding: 16px">
 | 
			
		||||
@@ -256,4 +309,4 @@ class TfProfileElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-profile', TfProfileElement);
 | 
			
		||||
customElements.define('tf-profile', TfProfileElement);
 | 
			
		||||
 
 | 
			
		||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							@@ -23,10 +23,10 @@ class TfTabConnectionsElement extends LitElement {
 | 
			
		||||
		this.connections = [];
 | 
			
		||||
		this.stored_connections = [];
 | 
			
		||||
		this.users = {};
 | 
			
		||||
		tfrpc.rpc.getAllIdentities().then(function(identities) {
 | 
			
		||||
		tfrpc.rpc.getAllIdentities().then(function (identities) {
 | 
			
		||||
			self.identities = identities || [];
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.rpc.getStoredConnections().then(function(connections) {
 | 
			
		||||
		tfrpc.rpc.getStoredConnections().then(function (connections) {
 | 
			
		||||
			self.stored_connections = connections || [];
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
@@ -43,10 +43,12 @@ class TfTabConnectionsElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	render_room_peers(connection) {
 | 
			
		||||
		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) {
 | 
			
		||||
			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)}`)}`;
 | 
			
		||||
			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)}`)}`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -58,7 +60,12 @@ class TfTabConnectionsElement extends LitElement {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return html`
 | 
			
		||||
			<li>
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)}>Connect</button>
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-dark-grey"
 | 
			
		||||
					@click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)}
 | 
			
		||||
				>
 | 
			
		||||
					Connect
 | 
			
		||||
				</button>
 | 
			
		||||
				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> 📡
 | 
			
		||||
			</li>
 | 
			
		||||
		`;
 | 
			
		||||
@@ -67,7 +74,12 @@ class TfTabConnectionsElement extends LitElement {
 | 
			
		||||
	render_broadcast(connection) {
 | 
			
		||||
		return html`
 | 
			
		||||
			<li>
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(connection)}>Connect</button>
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-dark-grey"
 | 
			
		||||
					@click=${() => tfrpc.rpc.connect(connection)}
 | 
			
		||||
				>
 | 
			
		||||
					Connect
 | 
			
		||||
				</button>
 | 
			
		||||
				<tf-user id=${connection.pubkey} .users=${this.users}></tf-user>
 | 
			
		||||
				${this.render_connection_summary(connection)}
 | 
			
		||||
			</li>
 | 
			
		||||
@@ -81,11 +93,20 @@ class TfTabConnectionsElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	render_connection(connection) {
 | 
			
		||||
		return html`
 | 
			
		||||
			<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.closeConnection(connection.id)}>Close</button>
 | 
			
		||||
			<button
 | 
			
		||||
				class="w3-button w3-dark-grey"
 | 
			
		||||
				@click=${() => tfrpc.rpc.closeConnection(connection.id)}
 | 
			
		||||
			>
 | 
			
		||||
				Close
 | 
			
		||||
			</button>
 | 
			
		||||
			<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})`}
 | 
			
		||||
			<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)}
 | 
			
		||||
			</ul>
 | 
			
		||||
		`;
 | 
			
		||||
@@ -97,34 +118,58 @@ class TfTabConnectionsElement extends LitElement {
 | 
			
		||||
			<div class="w3-container">
 | 
			
		||||
				<h2>New Connection</h2>
 | 
			
		||||
				<textarea class="w3-input w3-dark-grey" 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-dark-grey"
 | 
			
		||||
					@click=${() =>
 | 
			
		||||
						tfrpc.rpc.connect(self.renderRoot.getElementById('code').value)}
 | 
			
		||||
				>
 | 
			
		||||
					Connect
 | 
			
		||||
				</button>
 | 
			
		||||
				<h2>Broadcasts</h2>
 | 
			
		||||
				<ul>
 | 
			
		||||
					${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>
 | 
			
		||||
				<h2>Connections</h2>
 | 
			
		||||
				<ul>
 | 
			
		||||
					${this.connections.filter(x => x.tunnel === undefined).map(x => html`
 | 
			
		||||
						<li>${this.render_connection(x)}</li>
 | 
			
		||||
					`)}
 | 
			
		||||
					${this.connections
 | 
			
		||||
						.filter((x) => x.tunnel === undefined)
 | 
			
		||||
						.map((x) => html` <li>${this.render_connection(x)}</li> `)}
 | 
			
		||||
				</ul>
 | 
			
		||||
				<h2>Stored Connections (WIP)</h2>
 | 
			
		||||
				<ul>
 | 
			
		||||
					${this.stored_connections.map(x => html`
 | 
			
		||||
						<li>
 | 
			
		||||
							<button class="w3-button w3-dark-grey" @click=${() => self.forget_stored_connection(x)}>Forget</button>
 | 
			
		||||
							<button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(x)}>Connect</button>
 | 
			
		||||
							${x.address}:${x.port} <tf-user id=${x.pubkey} .users=${self.users}></tf-user>
 | 
			
		||||
						</li>
 | 
			
		||||
					`)}
 | 
			
		||||
					${this.stored_connections.map(
 | 
			
		||||
						(x) => html`
 | 
			
		||||
							<li>
 | 
			
		||||
								<button
 | 
			
		||||
									class="w3-button w3-dark-grey"
 | 
			
		||||
									@click=${() => self.forget_stored_connection(x)}
 | 
			
		||||
								>
 | 
			
		||||
									Forget
 | 
			
		||||
								</button>
 | 
			
		||||
								<button
 | 
			
		||||
									class="w3-button w3-dark-grey"
 | 
			
		||||
									@click=${() => tfrpc.rpc.connect(x)}
 | 
			
		||||
								>
 | 
			
		||||
									Connect
 | 
			
		||||
								</button>
 | 
			
		||||
								${x.address}:${x.port}
 | 
			
		||||
								<tf-user id=${x.pubkey} .users=${self.users}></tf-user>
 | 
			
		||||
							</li>
 | 
			
		||||
						`
 | 
			
		||||
					)}
 | 
			
		||||
				</ul>
 | 
			
		||||
				<h2>Local Accounts</h2>
 | 
			
		||||
				<ul>
 | 
			
		||||
					${this.identities.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)}
 | 
			
		||||
					${this.identities.map(
 | 
			
		||||
						(x) =>
 | 
			
		||||
							html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`
 | 
			
		||||
					)}
 | 
			
		||||
				</ul>
 | 
			
		||||
			</div>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-tab-connections', TfTabConnectionsElement);
 | 
			
		||||
customElements.define('tf-tab-connections', TfTabConnectionsElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -27,15 +27,21 @@ class TfTabMentionsElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	async load() {
 | 
			
		||||
		console.log('Loading...', this.whoami);
 | 
			
		||||
		let results = await tfrpc.rpc.query(`
 | 
			
		||||
				SELECT messages.*
 | 
			
		||||
		let results = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
				FROM messages_fts(?)
 | 
			
		||||
				JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
				JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
				WHERE messages.author != ?
 | 
			
		||||
				ORDER BY timestamp DESC limit 20
 | 
			
		||||
			`,
 | 
			
		||||
			['"' + this.whoami.replace('"', '""') + '"', JSON.stringify(this.following), this.whoami]);
 | 
			
		||||
			[
 | 
			
		||||
				'"' + this.whoami.replace('"', '""') + '"',
 | 
			
		||||
				JSON.stringify(this.following),
 | 
			
		||||
				this.whoami,
 | 
			
		||||
			]
 | 
			
		||||
		);
 | 
			
		||||
		console.log('Done.');
 | 
			
		||||
		this.messages = results;
 | 
			
		||||
	}
 | 
			
		||||
@@ -58,8 +64,15 @@ class TfTabMentionsElement extends LitElement {
 | 
			
		||||
			this.load();
 | 
			
		||||
		}
 | 
			
		||||
		return html`
 | 
			
		||||
			<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users} .expanded=${this.expanded} @tf-expand=${this.on_expand}></tf-news>
 | 
			
		||||
			<tf-news
 | 
			
		||||
				id="news"
 | 
			
		||||
				whoami=${this.whoami}
 | 
			
		||||
				.messages=${this.messages}
 | 
			
		||||
				.users=${this.users}
 | 
			
		||||
				.expanded=${this.expanded}
 | 
			
		||||
				@tf-expand=${this.on_expand}
 | 
			
		||||
			></tf-news>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
customElements.define('tf-tab-mentions', TfTabMentionsElement);
 | 
			
		||||
customElements.define('tf-tab-mentions', TfTabMentionsElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -33,69 +33,70 @@ class TfTabNewsFeedElement extends LitElement {
 | 
			
		||||
		if (this.hash.startsWith('#@')) {
 | 
			
		||||
			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
 | 
			
		||||
						WHERE messages.author = ?
 | 
			
		||||
						ORDER BY sequence DESC
 | 
			
		||||
						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
 | 
			
		||||
						JOIN messages_refs ON mine.id = messages_refs.ref
 | 
			
		||||
						JOIN messages ON messages_refs.message = messages.id
 | 
			
		||||
					UNION
 | 
			
		||||
					SELECT * FROM mine
 | 
			
		||||
				`,
 | 
			
		||||
				[
 | 
			
		||||
					this.hash.substring(1),
 | 
			
		||||
				]);
 | 
			
		||||
				[this.hash.substring(1)]
 | 
			
		||||
			);
 | 
			
		||||
			return r;
 | 
			
		||||
		} else if (this.hash.startsWith('#%')) {
 | 
			
		||||
			return await tfrpc.rpc.query(
 | 
			
		||||
				`
 | 
			
		||||
					SELECT messages.*
 | 
			
		||||
					SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
 | 
			
		||||
					FROM messages
 | 
			
		||||
					WHERE id = ?1
 | 
			
		||||
					UNION
 | 
			
		||||
					SELECT messages.*
 | 
			
		||||
					SELECT id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
 | 
			
		||||
					FROM messages JOIN messages_refs
 | 
			
		||||
					ON messages.id = messages_refs.message
 | 
			
		||||
					WHERE messages_refs.ref = ?1
 | 
			
		||||
				`,
 | 
			
		||||
				[
 | 
			
		||||
					this.hash.substring(1),
 | 
			
		||||
				]);
 | 
			
		||||
				[this.hash.substring(1)]
 | 
			
		||||
			);
 | 
			
		||||
		} else {
 | 
			
		||||
			let promises = [];
 | 
			
		||||
			const k_following_limit = 256;
 | 
			
		||||
			for (let i = 0; i < this.following.length; i += k_following_limit) {
 | 
			
		||||
				promises.push(tfrpc.rpc.query(
 | 
			
		||||
					`
 | 
			
		||||
						WITH news AS (SELECT messages.*
 | 
			
		||||
				promises.push(
 | 
			
		||||
					tfrpc.rpc.query(
 | 
			
		||||
						`
 | 
			
		||||
						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
 | 
			
		||||
						JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
						WHERE messages.timestamp > ? AND messages.timestamp < ?
 | 
			
		||||
						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
 | 
			
		||||
							JOIN messages_refs ON news.id = messages_refs.ref
 | 
			
		||||
							JOIN messages ON messages_refs.message = messages.id
 | 
			
		||||
						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
 | 
			
		||||
							JOIN messages_refs ON news.id = messages_refs.message
 | 
			
		||||
							JOIN messages ON messages_refs.ref = messages.id
 | 
			
		||||
						UNION
 | 
			
		||||
						SELECT news.* FROM news
 | 
			
		||||
					`,
 | 
			
		||||
					[
 | 
			
		||||
						JSON.stringify(this.following.slice(i, i + k_following_limit)),
 | 
			
		||||
						this.start_time,
 | 
			
		||||
						/*
 | 
			
		||||
						** Don't show messages more than a day into the future to prevent
 | 
			
		||||
						** messages with far-future timestamps from staying at the top forever.
 | 
			
		||||
						*/
 | 
			
		||||
						new Date().valueOf() + 24 * 60 * 60 * 1000,
 | 
			
		||||
					]));
 | 
			
		||||
						[
 | 
			
		||||
							JSON.stringify(this.following.slice(i, i + k_following_limit)),
 | 
			
		||||
							this.start_time,
 | 
			
		||||
							/*
 | 
			
		||||
							 ** Don't show messages more than a day into the future to prevent
 | 
			
		||||
							 ** messages with far-future timestamps from staying at the top forever.
 | 
			
		||||
							 */
 | 
			
		||||
							new Date().valueOf() + 24 * 60 * 60 * 1000,
 | 
			
		||||
						]
 | 
			
		||||
					)
 | 
			
		||||
				);
 | 
			
		||||
			}
 | 
			
		||||
			return [].concat(...(await Promise.all(promises)));
 | 
			
		||||
		}
 | 
			
		||||
@@ -106,29 +107,26 @@ class TfTabNewsFeedElement extends LitElement {
 | 
			
		||||
		this.start_time = last_start_time - 24 * 60 * 60 * 1000;
 | 
			
		||||
		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
 | 
			
		||||
				JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
				WHERE messages.timestamp > ?
 | 
			
		||||
				AND messages.timestamp <= ?
 | 
			
		||||
				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
 | 
			
		||||
					JOIN messages_refs ON news.id = messages_refs.ref
 | 
			
		||||
					JOIN messages ON messages_refs.message = messages.id
 | 
			
		||||
				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
 | 
			
		||||
					JOIN messages_refs ON news.id = messages_refs.message
 | 
			
		||||
					JOIN messages ON messages_refs.ref = messages.id
 | 
			
		||||
				UNION
 | 
			
		||||
				SELECT news.* FROM news
 | 
			
		||||
			`,
 | 
			
		||||
			[
 | 
			
		||||
				JSON.stringify(this.following),
 | 
			
		||||
				this.start_time,
 | 
			
		||||
				last_start_time,
 | 
			
		||||
			]);
 | 
			
		||||
			[JSON.stringify(this.following), this.start_time, last_start_time]
 | 
			
		||||
		);
 | 
			
		||||
		this.messages = await this.decrypt([...more, ...this.messages]);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -139,14 +137,12 @@ class TfTabNewsFeedElement extends LitElement {
 | 
			
		||||
			let content;
 | 
			
		||||
			try {
 | 
			
		||||
				content = JSON.parse(message?.content);
 | 
			
		||||
			} catch {
 | 
			
		||||
			}
 | 
			
		||||
			if (typeof(content) === 'string') {
 | 
			
		||||
			} catch {}
 | 
			
		||||
			if (typeof content === 'string') {
 | 
			
		||||
				let decrypted;
 | 
			
		||||
				try {
 | 
			
		||||
					decrypted = await tfrpc.rpc.try_decrypt(this.whoami, content);
 | 
			
		||||
				} catch {
 | 
			
		||||
				}
 | 
			
		||||
				} catch {}
 | 
			
		||||
				if (decrypted) {
 | 
			
		||||
					try {
 | 
			
		||||
						message.decrypted = JSON.parse(decrypted);
 | 
			
		||||
@@ -165,34 +161,51 @@ class TfTabNewsFeedElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		if (!this.messages ||
 | 
			
		||||
		if (
 | 
			
		||||
			!this.messages ||
 | 
			
		||||
			this._messages_hash !== this.hash ||
 | 
			
		||||
			this._messages_following !== this.following) {
 | 
			
		||||
			console.log(`loading messages for ${this.whoami} (following ${this.following.length})`);
 | 
			
		||||
			this._messages_following !== this.following
 | 
			
		||||
		) {
 | 
			
		||||
			console.log(
 | 
			
		||||
				`loading messages for ${this.whoami} (following ${this.following.length})`
 | 
			
		||||
			);
 | 
			
		||||
			let self = this;
 | 
			
		||||
			this.messages = [];
 | 
			
		||||
			this._messages_hash = this.hash;
 | 
			
		||||
			this._messages_following = this.following;
 | 
			
		||||
			this.fetch_messages().then(this.decrypt.bind(this)).then(function(messages) {
 | 
			
		||||
				self.messages = messages;
 | 
			
		||||
				console.log(`loading mesages done for ${self.whoami}`);
 | 
			
		||||
			}).catch(function(error) {
 | 
			
		||||
				alert(JSON.stringify(error, null, 2));
 | 
			
		||||
			});
 | 
			
		||||
			this.fetch_messages()
 | 
			
		||||
				.then(this.decrypt.bind(this))
 | 
			
		||||
				.then(function (messages) {
 | 
			
		||||
					self.messages = messages;
 | 
			
		||||
					console.log(`loading mesages done for ${self.whoami}`);
 | 
			
		||||
				})
 | 
			
		||||
				.catch(function (error) {
 | 
			
		||||
					alert(JSON.stringify(error, null, 2));
 | 
			
		||||
				});
 | 
			
		||||
		}
 | 
			
		||||
		let more;
 | 
			
		||||
		if (!this.hash.startsWith('#@') && !this.hash.startsWith('#%')) {
 | 
			
		||||
			more = html`
 | 
			
		||||
				<p>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.load_more}>Load More</button>
 | 
			
		||||
					<button class="w3-button w3-dark-grey" @click=${this.load_more}>
 | 
			
		||||
						Load More
 | 
			
		||||
					</button>
 | 
			
		||||
				</p>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
		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}
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-tab-news-feed', TfTabNewsFeedElement);
 | 
			
		||||
customElements.define('tf-tab-news-feed', TfTabNewsFeedElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -28,7 +28,7 @@ class TfTabNewsElement extends LitElement {
 | 
			
		||||
		this.cache = {};
 | 
			
		||||
		this.drafts = {};
 | 
			
		||||
		this.expanded = {};
 | 
			
		||||
		tfrpc.rpc.localStorageGet('drafts').then(function(d) {
 | 
			
		||||
		tfrpc.rpc.localStorageGet('drafts').then(function (d) {
 | 
			
		||||
			self.drafts = JSON.parse(d || '{}');
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
@@ -48,7 +48,9 @@ class TfTabNewsElement extends LitElement {
 | 
			
		||||
		let news = this.shadowRoot?.getElementById('news');
 | 
			
		||||
		if (news) {
 | 
			
		||||
			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'));
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -62,11 +64,16 @@ class TfTabNewsElement extends LitElement {
 | 
			
		||||
			let type = 'private';
 | 
			
		||||
			try {
 | 
			
		||||
				type = JSON.parse(message.content).type || type;
 | 
			
		||||
			} catch {
 | 
			
		||||
			}
 | 
			
		||||
			} catch {}
 | 
			
		||||
			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) {
 | 
			
		||||
@@ -96,23 +103,52 @@ class TfTabNewsElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	on_keypress(event) {
 | 
			
		||||
		if (event.target === document.body &&
 | 
			
		||||
			event.key == '.') {
 | 
			
		||||
		if (event.target === document.body && event.key == '.') {
 | 
			
		||||
			this.show_more();
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let profile = this.hash.startsWith('#@') ?
 | 
			
		||||
			html`<tf-profile id=${this.hash.substring(1)} whoami=${this.whoami} .users=${this.users}></tf-profile>` : undefined;
 | 
			
		||||
		let profile = this.hash.startsWith('#@')
 | 
			
		||||
			? html`<tf-profile
 | 
			
		||||
					id=${this.hash.substring(1)}
 | 
			
		||||
					whoami=${this.whoami}
 | 
			
		||||
					.users=${this.users}
 | 
			
		||||
				></tf-profile>`
 | 
			
		||||
			: undefined;
 | 
			
		||||
		return html`
 | 
			
		||||
			<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-dark-grey"
 | 
			
		||||
					@click=${this.show_more}
 | 
			
		||||
				>
 | 
			
		||||
					${this.new_messages_text()}
 | 
			
		||||
				</button>
 | 
			
		||||
			</p>
 | 
			
		||||
			<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>
 | 
			
		||||
			<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}
 | 
			
		||||
			<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));
 | 
			
		||||
		let start_time = new Date();
 | 
			
		||||
		try {
 | 
			
		||||
			this.results = await tfrpc.rpc.query(query, [])
 | 
			
		||||
			this.results = await tfrpc.rpc.query(query, []);
 | 
			
		||||
		} catch (error) {
 | 
			
		||||
			this.error = error;
 | 
			
		||||
		}
 | 
			
		||||
@@ -79,8 +79,15 @@ class TfTabQueryElement extends LitElement {
 | 
			
		||||
		} else {
 | 
			
		||||
			let keys = Object.keys(this.results[0]).sort();
 | 
			
		||||
			return html`<table style="width: 100%; max-width: 100%">
 | 
			
		||||
				<tr>${keys.map(key => html`<th>${key}</th>`)}</tr>
 | 
			
		||||
				${this.results.map(row => html`<tr>${keys.map(key => html`<td>${row[key]}</td>`)}</tr>`)}
 | 
			
		||||
				<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>`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -100,15 +107,30 @@ class TfTabQueryElement extends LitElement {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return html`
 | 
			
		||||
			<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>
 | 
			
		||||
				<button class="w3-button w3-dark-grey" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Execute</button>
 | 
			
		||||
				<textarea
 | 
			
		||||
					id="search"
 | 
			
		||||
					rows="8"
 | 
			
		||||
					class="w3-input w3-dark-grey"
 | 
			
		||||
					style="flex: 1; resize: vertical"
 | 
			
		||||
					@keydown=${this.search_keydown}
 | 
			
		||||
				>
 | 
			
		||||
${this.query}</textarea
 | 
			
		||||
				>
 | 
			
		||||
				<button
 | 
			
		||||
					class="w3-button w3-dark-grey"
 | 
			
		||||
					@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 ?hidden=${this.duration === undefined}>Took ${this.duration / 1000.0} seconds.</div>
 | 
			
		||||
			<div ?hidden=${this.duration !== undefined}>Executing...</div>
 | 
			
		||||
			${this.render_error()}
 | 
			
		||||
			${this.render_results()}
 | 
			
		||||
			${this.render_error()} ${this.render_results()}
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-tab-query', TfTabQueryElement);
 | 
			
		||||
customElements.define('tf-tab-query', TfTabQueryElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -27,23 +27,25 @@ class TfTabSearchElement extends LitElement {
 | 
			
		||||
	async search(query) {
 | 
			
		||||
		console.log('Searching...', this.whoami, query);
 | 
			
		||||
		let search = this.renderRoot.getElementById('search');
 | 
			
		||||
		if (search ) {
 | 
			
		||||
		if (search) {
 | 
			
		||||
			search.value = query;
 | 
			
		||||
			search.focus();
 | 
			
		||||
			search.select();
 | 
			
		||||
		}
 | 
			
		||||
		await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query));
 | 
			
		||||
		let results = await tfrpc.rpc.query(`
 | 
			
		||||
				SELECT messages.*
 | 
			
		||||
		let results = await tfrpc.rpc.query(
 | 
			
		||||
			`
 | 
			
		||||
				SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
 | 
			
		||||
				FROM messages_fts(?)
 | 
			
		||||
				JOIN messages ON messages.rowid = messages_fts.rowid
 | 
			
		||||
				JOIN json_each(?) AS following ON messages.author = following.value
 | 
			
		||||
				ORDER BY timestamp DESC limit 100
 | 
			
		||||
			`,
 | 
			
		||||
			['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)]);
 | 
			
		||||
			['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)]
 | 
			
		||||
		);
 | 
			
		||||
		console.log('Done.');
 | 
			
		||||
		search = this.renderRoot.getElementById('search');
 | 
			
		||||
		if (search ) {
 | 
			
		||||
		if (search) {
 | 
			
		||||
			search.value = query;
 | 
			
		||||
			search.focus();
 | 
			
		||||
			search.select();
 | 
			
		||||
@@ -84,4 +86,4 @@ class TfTabSearchElement extends LitElement {
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-tab-search', TfTabSearchElement);
 | 
			
		||||
customElements.define('tf-tab-search', TfTabSearchElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -17,8 +17,12 @@ class TfTagElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let number = this.count ? html` (${this.count})` : undefined;
 | 
			
		||||
		return html`<a href="#q=${this.tag}" style="display: inline-block; margin: 3px; border: 1px solid black; background-color: #444; padding: 4px; border-radius: 3px">${this.tag}${number}</a>`;
 | 
			
		||||
		return html`<a
 | 
			
		||||
			href="#q=${this.tag}"
 | 
			
		||||
			style="display: inline-block; margin: 3px; border: 1px solid black; background-color: #444; padding: 4px; border-radius: 3px"
 | 
			
		||||
			>${this.tag}${number}</a
 | 
			
		||||
		>`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-tag', TfTagElement);
 | 
			
		||||
customElements.define('tf-tag', TfTagElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -20,25 +20,28 @@ class TfUserElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let name = this.users?.[this.id]?.name;
 | 
			
		||||
		name = name !== undefined ?
 | 
			
		||||
			html`<a target="_top" href=${'#' + this.id}>${name}</a>` :
 | 
			
		||||
			html`<a target="_top" href=${'#' + this.id}>${this.id}</a>`;
 | 
			
		||||
		name =
 | 
			
		||||
			name !== undefined
 | 
			
		||||
				? html`<a target="_top" href=${'#' + this.id}>${name}</a>`
 | 
			
		||||
				: html`<a target="_top" href=${'#' + this.id}>${this.id}</a>`;
 | 
			
		||||
 | 
			
		||||
		if (this.users[this.id]) {
 | 
			
		||||
			let image = this.users[this.id].image;
 | 
			
		||||
			image = typeof(image) == 'string' ? image : image?.link;
 | 
			
		||||
			return html`
 | 
			
		||||
				<div style="display: inline-block; font-weight: bold">
 | 
			
		||||
						<img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" ?hidden=${image === undefined} src="${image ? '/' + image + '/view' : undefined}">
 | 
			
		||||
						${name}
 | 
			
		||||
				</div>`;
 | 
			
		||||
			image = typeof image == 'string' ? image : image?.link;
 | 
			
		||||
			return html` <div style="display: inline-block; font-weight: bold">
 | 
			
		||||
				<img
 | 
			
		||||
					style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%"
 | 
			
		||||
					?hidden=${image === undefined}
 | 
			
		||||
					src="${image ? '/' + image + '/view' : undefined}"
 | 
			
		||||
				/>
 | 
			
		||||
				${name}
 | 
			
		||||
			</div>`;
 | 
			
		||||
		} else {
 | 
			
		||||
			return html`
 | 
			
		||||
				<div style="display: inline-block; font-weight: bold">
 | 
			
		||||
					${name}
 | 
			
		||||
				</div>`;
 | 
			
		||||
			return html` <div style="display: inline-block; font-weight: bold">
 | 
			
		||||
				${name}
 | 
			
		||||
			</div>`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-user', TfUserElement);
 | 
			
		||||
customElements.define('tf-user', TfUserElement);
 | 
			
		||||
 
 | 
			
		||||
@@ -2,20 +2,32 @@ import * as linkify from './commonmark-linkify.js';
 | 
			
		||||
import * as hashtagify from './commonmark-hashtag.js';
 | 
			
		||||
 | 
			
		||||
function image(node, entering) {
 | 
			
		||||
	if (node.firstChild?.type === 'text' &&
 | 
			
		||||
		node.firstChild.literal.startsWith('video:')) {
 | 
			
		||||
	if (
 | 
			
		||||
		node.firstChild?.type === 'text' &&
 | 
			
		||||
		node.firstChild.literal.startsWith('video:')
 | 
			
		||||
	) {
 | 
			
		||||
		if (entering) {
 | 
			
		||||
			this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>');
 | 
			
		||||
			this.lit(
 | 
			
		||||
				'<video style="max-width: 100%; max-height: 480px" title="' +
 | 
			
		||||
					this.esc(node.firstChild?.literal) +
 | 
			
		||||
					'" controls>'
 | 
			
		||||
			);
 | 
			
		||||
			this.lit('<source src="' + this.esc(node.destination) + '"></source>');
 | 
			
		||||
			this.disableTags += 1;
 | 
			
		||||
		} else {
 | 
			
		||||
			this.disableTags -= 1;
 | 
			
		||||
			this.lit('</video>');
 | 
			
		||||
		}
 | 
			
		||||
	} else if (node.firstChild?.type === 'text' &&
 | 
			
		||||
		node.firstChild.literal.startsWith('audio:')) {
 | 
			
		||||
	} else if (
 | 
			
		||||
		node.firstChild?.type === 'text' &&
 | 
			
		||||
		node.firstChild.literal.startsWith('audio:')
 | 
			
		||||
	) {
 | 
			
		||||
		if (entering) {
 | 
			
		||||
			this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>');
 | 
			
		||||
			this.lit(
 | 
			
		||||
				'<audio style="height: 32px; max-width: 100%" title="' +
 | 
			
		||||
					this.esc(node.firstChild?.literal) +
 | 
			
		||||
					'" controls>'
 | 
			
		||||
			);
 | 
			
		||||
			this.lit('<source src="' + this.esc(node.destination) + '"></source>');
 | 
			
		||||
			this.disableTags += 1;
 | 
			
		||||
		} else {
 | 
			
		||||
@@ -25,7 +37,11 @@ function image(node, entering) {
 | 
			
		||||
	} else {
 | 
			
		||||
		if (entering) {
 | 
			
		||||
			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)) {
 | 
			
		||||
					this.lit('<img src="" alt="');
 | 
			
		||||
				} else {
 | 
			
		||||
@@ -58,14 +74,20 @@ export function markdown(md) {
 | 
			
		||||
		node = event.node;
 | 
			
		||||
		if (event.entering) {
 | 
			
		||||
			if (node.type == 'link') {
 | 
			
		||||
				if (node.destination.startsWith('@') &&
 | 
			
		||||
					node.destination.endsWith('.ed25519')) {
 | 
			
		||||
				if (
 | 
			
		||||
					node.destination.startsWith('@') &&
 | 
			
		||||
					node.destination.endsWith('.ed25519')
 | 
			
		||||
				) {
 | 
			
		||||
					node.destination = '#' + node.destination;
 | 
			
		||||
				} else if (node.destination.startsWith('%') &&
 | 
			
		||||
					node.destination.endsWith('.sha256')) {
 | 
			
		||||
				} else if (
 | 
			
		||||
					node.destination.startsWith('%') &&
 | 
			
		||||
					node.destination.endsWith('.sha256')
 | 
			
		||||
				) {
 | 
			
		||||
					node.destination = '#' + node.destination;
 | 
			
		||||
				} else if (node.destination.startsWith('&') &&
 | 
			
		||||
					node.destination.endsWith('.sha256')) {
 | 
			
		||||
				} else if (
 | 
			
		||||
					node.destination.startsWith('&') &&
 | 
			
		||||
					node.destination.endsWith('.sha256')
 | 
			
		||||
				) {
 | 
			
		||||
					node.destination = '/' + node.destination + '/view';
 | 
			
		||||
				}
 | 
			
		||||
			} else if (node.type == 'image') {
 | 
			
		||||
@@ -90,4 +112,4 @@ export function human_readable_size(bytes) {
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
	return `${Math.round(v * 10) / 10} ${u}`;
 | 
			
		||||
}
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,32 +1,32 @@
 | 
			
		||||
.tribute-container {
 | 
			
		||||
  position: absolute;
 | 
			
		||||
  top: 0;
 | 
			
		||||
  left: 0;
 | 
			
		||||
  height: auto;
 | 
			
		||||
  overflow: auto;
 | 
			
		||||
  display: block;
 | 
			
		||||
  z-index: 999999;
 | 
			
		||||
	position: absolute;
 | 
			
		||||
	top: 0;
 | 
			
		||||
	left: 0;
 | 
			
		||||
	height: auto;
 | 
			
		||||
	overflow: auto;
 | 
			
		||||
	display: block;
 | 
			
		||||
	z-index: 999999;
 | 
			
		||||
}
 | 
			
		||||
.tribute-container ul {
 | 
			
		||||
  margin: 0;
 | 
			
		||||
  margin-top: 2px;
 | 
			
		||||
  padding: 0;
 | 
			
		||||
  list-style: none;
 | 
			
		||||
  background: #efefef;
 | 
			
		||||
	margin: 0;
 | 
			
		||||
	margin-top: 2px;
 | 
			
		||||
	padding: 0;
 | 
			
		||||
	list-style: none;
 | 
			
		||||
	background: #efefef;
 | 
			
		||||
}
 | 
			
		||||
.tribute-container li {
 | 
			
		||||
  padding: 5px 5px;
 | 
			
		||||
  cursor: pointer;
 | 
			
		||||
	padding: 5px 5px;
 | 
			
		||||
	cursor: pointer;
 | 
			
		||||
}
 | 
			
		||||
.tribute-container li.highlight {
 | 
			
		||||
  background: #ddd;
 | 
			
		||||
	background: #ddd;
 | 
			
		||||
}
 | 
			
		||||
.tribute-container li span {
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
	font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
.tribute-container li.no-match {
 | 
			
		||||
  cursor: default;
 | 
			
		||||
	cursor: default;
 | 
			
		||||
}
 | 
			
		||||
.tribute-container .menu-highlighted {
 | 
			
		||||
  font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
	font-weight: bold;
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "☑️"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "☑️"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -27,7 +27,8 @@ async function todo_add(list) {
 | 
			
		||||
		let set = new Set(names);
 | 
			
		||||
		set.add(list);
 | 
			
		||||
		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;
 | 
			
		||||
}
 | 
			
		||||
@@ -42,7 +43,8 @@ async function todo_remove(list) {
 | 
			
		||||
		let set = new Set(names);
 | 
			
		||||
		set.delete(list);
 | 
			
		||||
		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);
 | 
			
		||||
	return exchanged;
 | 
			
		||||
@@ -79,4 +81,4 @@ async function main() {
 | 
			
		||||
	await app.setDocument(utf8Decode(getFile('index.html')));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,4 +1,4 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html>
 | 
			
		||||
	<head>
 | 
			
		||||
		<title>TODO</title>
 | 
			
		||||
@@ -8,4 +8,4 @@
 | 
			
		||||
		<tf-todos></tf-todos>
 | 
			
		||||
	</body>
 | 
			
		||||
	<script src="script.js" type="module"></script>
 | 
			
		||||
</html>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -4,7 +4,7 @@ import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
class TodosElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			lists: {type: Array}
 | 
			
		||||
			lists: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -12,11 +12,14 @@ class TodosElement extends LitElement {
 | 
			
		||||
		super();
 | 
			
		||||
		this.lists = [];
 | 
			
		||||
		let self = this;
 | 
			
		||||
		tfrpc.rpc.todo_get_all().then(function(lists) {
 | 
			
		||||
			self.lists = lists;
 | 
			
		||||
		}).catch(function(error) {
 | 
			
		||||
			console.log(error);
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.rpc
 | 
			
		||||
			.todo_get_all()
 | 
			
		||||
			.then(function (lists) {
 | 
			
		||||
				self.lists = lists;
 | 
			
		||||
			})
 | 
			
		||||
			.catch(function (error) {
 | 
			
		||||
				console.log(error);
 | 
			
		||||
			});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async new_list() {
 | 
			
		||||
@@ -32,9 +35,15 @@ class TodosElement extends LitElement {
 | 
			
		||||
		return html`
 | 
			
		||||
			<div>
 | 
			
		||||
				<div style="display: flex">
 | 
			
		||||
					${this.lists.map(x => html`
 | 
			
		||||
						<tf-todo-list name=${x.name} .items=${x.items} @change=${this.refresh}></tf-todo-list>
 | 
			
		||||
					`)}
 | 
			
		||||
					${this.lists.map(
 | 
			
		||||
						(x) => html`
 | 
			
		||||
							<tf-todo-list
 | 
			
		||||
								name=${x.name}
 | 
			
		||||
								.items=${x.items}
 | 
			
		||||
								@change=${this.refresh}
 | 
			
		||||
							></tf-todo-list>
 | 
			
		||||
						`
 | 
			
		||||
					)}
 | 
			
		||||
				</div>
 | 
			
		||||
				<input type="button" @click=${this.new_list} value="+ List"></input>
 | 
			
		||||
			</div>`;
 | 
			
		||||
@@ -59,16 +68,22 @@ class TodoListElement extends LitElement {
 | 
			
		||||
	save() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		console.log('saving', self.name, self.items);
 | 
			
		||||
		tfrpc.rpc.todo_set(self.name, self.items).then(function() {
 | 
			
		||||
			console.log('saved', self.name, self.items);
 | 
			
		||||
		}).catch(function(error) {
 | 
			
		||||
			console.log(error);
 | 
			
		||||
		});
 | 
			
		||||
		tfrpc.rpc
 | 
			
		||||
			.todo_set(self.name, self.items)
 | 
			
		||||
			.then(function () {
 | 
			
		||||
				console.log('saved', self.name, self.items);
 | 
			
		||||
			})
 | 
			
		||||
			.catch(function (error) {
 | 
			
		||||
				console.log(error);
 | 
			
		||||
			});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	remove_item(item) {
 | 
			
		||||
		let index = this.items.indexOf(item);
 | 
			
		||||
		this.items = [].concat(this.items.slice(0, index), this.items.slice(index + 1));
 | 
			
		||||
		this.items = [].concat(
 | 
			
		||||
			this.items.slice(0, index),
 | 
			
		||||
			this.items.slice(index + 1)
 | 
			
		||||
		);
 | 
			
		||||
		this.save();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
@@ -106,20 +121,20 @@ class TodoListElement extends LitElement {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		if (index === this.editing) {
 | 
			
		||||
			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
 | 
			
		||||
					id="edit"
 | 
			
		||||
					type="text"
 | 
			
		||||
					value=${item.text}
 | 
			
		||||
					@change=${event => self.input_change(event, item)}
 | 
			
		||||
					@keydown=${event => self.input_keydown(event, item)}
 | 
			
		||||
					@blur=${x => self.input_blur(item)}></input>
 | 
			
		||||
				<span @click=${x => self.remove_item(item)} style="cursor: pointer">❎</span></div>
 | 
			
		||||
					@change=${(event) => self.input_change(event, item)}
 | 
			
		||||
					@keydown=${(event) => self.input_keydown(event, item)}
 | 
			
		||||
					@blur=${(x) => self.input_blur(item)}></input>
 | 
			
		||||
				<span @click=${(x) => self.remove_item(item)} style="cursor: pointer">❎</span></div>
 | 
			
		||||
			`;
 | 
			
		||||
		} else {
 | 
			
		||||
			return html`
 | 
			
		||||
				<div><input type="checkbox" ?checked=${item.x} @change=${x => self.handle_check(x, item)}></input>
 | 
			
		||||
				<span @click=${x => self.editing = index}>${item.text || '(empty)'}</span>
 | 
			
		||||
				<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>
 | 
			
		||||
			`;
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
@@ -139,14 +154,17 @@ class TodoListElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	rename(new_name) {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		return tfrpc.rpc.todo_rename(this.name, new_name).then(function() {
 | 
			
		||||
			self.dispatchEvent(new Event('change'));
 | 
			
		||||
			self.editing_name = false;
 | 
			
		||||
		}).catch(function(error) {
 | 
			
		||||
			console.log(error);
 | 
			
		||||
			alert(error.message);
 | 
			
		||||
			self.editing_name = false;
 | 
			
		||||
		});
 | 
			
		||||
		return tfrpc.rpc
 | 
			
		||||
			.todo_rename(this.name, new_name)
 | 
			
		||||
			.then(function () {
 | 
			
		||||
				self.dispatchEvent(new Event('change'));
 | 
			
		||||
				self.editing_name = false;
 | 
			
		||||
			})
 | 
			
		||||
			.catch(function (error) {
 | 
			
		||||
				console.log(error);
 | 
			
		||||
				alert(error.message);
 | 
			
		||||
				self.editing_name = false;
 | 
			
		||||
			});
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	name_blur(new_name) {
 | 
			
		||||
@@ -163,19 +181,25 @@ class TodoListElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		let name = this.editing_name ?
 | 
			
		||||
			html`<input
 | 
			
		||||
		let name = this.editing_name
 | 
			
		||||
			? html`<input
 | 
			
		||||
				type="text"
 | 
			
		||||
				id="edit"
 | 
			
		||||
				@keydown=${event => self.name_keydown(event)}
 | 
			
		||||
				@blur=${event => self.name_blur(event.srcElement.value)}
 | 
			
		||||
				value=${this.name}></input>` :
 | 
			
		||||
			html`<h2 @click=${x => this.editing_name = true}>${this.name}</h2>`;
 | 
			
		||||
				@keydown=${(event) => self.name_keydown(event)}
 | 
			
		||||
				@blur=${(event) => self.name_blur(event.srcElement.value)}
 | 
			
		||||
				value=${this.name}></input>`
 | 
			
		||||
			: html`<h2 @click=${(x) => (this.editing_name = true)}>${this.name}</h2>`;
 | 
			
		||||
		return html`
 | 
			
		||||
			<div style="border: 3px solid black; padding: 8px; margin: 8px; border-radius: 8px; background-color: #444">
 | 
			
		||||
			<div
 | 
			
		||||
				style="border: 3px solid black; padding: 8px; margin: 8px; border-radius: 8px; background-color: #444"
 | 
			
		||||
			>
 | 
			
		||||
				${name}
 | 
			
		||||
				${(this.items || []).filter(item => !item.x).map(x => self.render_item(x))}
 | 
			
		||||
				${(this.items || []).filter(item => item.x).map(x => self.render_item(x))}
 | 
			
		||||
				${(this.items || [])
 | 
			
		||||
					.filter((item) => !item.x)
 | 
			
		||||
					.map((x) => self.render_item(x))}
 | 
			
		||||
				${(this.items || [])
 | 
			
		||||
					.filter((item) => item.x)
 | 
			
		||||
					.map((x) => self.render_item(x))}
 | 
			
		||||
				<button @click=${self.add_item}>+ Item</button>
 | 
			
		||||
				<button @click=${self.remove_list}>- List</button>
 | 
			
		||||
			</div>
 | 
			
		||||
@@ -184,4 +208,4 @@ class TodoListElement extends LitElement {
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-todo-list', TodoListElement);
 | 
			
		||||
customElements.define('tf-todos', TodosElement);
 | 
			
		||||
customElements.define('tf-todos', TodosElement);
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										4
									
								
								apps/user_settings.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										4
									
								
								apps/user_settings.json
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,4 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "⚙️"
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										50
									
								
								apps/user_settings/app.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								apps/user_settings/app.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,50 @@
 | 
			
		||||
import * as tfrpc from '/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
tfrpc.register(async function getIdentities() {
 | 
			
		||||
	return ssb.getIdentities();
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function createID(id) {
 | 
			
		||||
	return await ssb.createIdentity();
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function getPrivateKey(id) {
 | 
			
		||||
	return bip39Words(await ssb.getPrivateKey(id));
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function getThemes() {
 | 
			
		||||
	// TODO
 | 
			
		||||
	return ['solarized', 'gruvbox', 'light'];
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function setTheme() {
 | 
			
		||||
	// TODO
 | 
			
		||||
	console.warn("setTheme called - not implemented")
 | 
			
		||||
	return null;
 | 
			
		||||
});
 | 
			
		||||
tfrpc.register(async function reload() {
 | 
			
		||||
	await main();
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
async function main() {
 | 
			
		||||
	// Get body.html
 | 
			
		||||
	const body = utf8Decode(await getFile('body.html'));
 | 
			
		||||
 | 
			
		||||
	// Build the document
 | 
			
		||||
	const document = `
 | 
			
		||||
	<!DOCTYPE html>
 | 
			
		||||
	<html>
 | 
			
		||||
		<head>
 | 
			
		||||
			<link rel="stylesheet" href="/static/tildefriends-v1.css"/>
 | 
			
		||||
			<script src="tf-theme-picker.js" type="module"></script>
 | 
			
		||||
			<script src="tf-password-form.js" type="module"></script>
 | 
			
		||||
			<script src="tf-delete-account-btn.js" type="module"></script>
 | 
			
		||||
			<script src="tf-identity-manager.js" type="module"></script>
 | 
			
		||||
		</head>
 | 
			
		||||
 | 
			
		||||
		<body class="flex-column">
 | 
			
		||||
			${body}
 | 
			
		||||
		</body>
 | 
			
		||||
	</html>`;
 | 
			
		||||
 | 
			
		||||
	// Send it to the browser
 | 
			
		||||
	app.setDocument(document);
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
							
								
								
									
										20
									
								
								apps/user_settings/body.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								apps/user_settings/body.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
			
		||||
<h1>Your settings</h1>
 | 
			
		||||
 | 
			
		||||
<div class="box flex-column">
 | 
			
		||||
	<h2>Appearance</h2>
 | 
			
		||||
 | 
			
		||||
	<tf-theme-picker></tf-theme-picker>
 | 
			
		||||
</div>
 | 
			
		||||
 | 
			
		||||
<div class="box flex-column">
 | 
			
		||||
	<h2>Danger Zone</h2>
 | 
			
		||||
 | 
			
		||||
	<h3>Manage your identities</h3>
 | 
			
		||||
	<tf-identity-manager></tf-identity-manager>
 | 
			
		||||
	
 | 
			
		||||
	<h3>Change my password</h3>
 | 
			
		||||
	<tf-password-form></tf-password-form>
 | 
			
		||||
 | 
			
		||||
	<h3>Delete your account</h3>
 | 
			
		||||
	<tf-delete-account-btn></tf-delete-account-btn>
 | 
			
		||||
</div>
 | 
			
		||||
							
								
								
									
										1
									
								
								apps/user_settings/script.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/user_settings/script.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
/* */
 | 
			
		||||
							
								
								
									
										1
									
								
								apps/user_settings/style.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								apps/user_settings/style.css
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
/* */
 | 
			
		||||
							
								
								
									
										36
									
								
								apps/user_settings/tf-delete-account-btn.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								apps/user_settings/tf-delete-account-btn.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
class TfDeleteAccountButtonElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	deleteAccount() {
 | 
			
		||||
		const res = confirm(
 | 
			
		||||
			'Are you really sure you want to delete your account ?'
 | 
			
		||||
		);
 | 
			
		||||
 | 
			
		||||
		if (!res) return;
 | 
			
		||||
 | 
			
		||||
		console.warn('TODO');
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		return html`
 | 
			
		||||
			<link rel="stylesheet" href="/static/tildefriends-v1.css"/>
 | 
			
		||||
 | 
			
		||||
			<span>This action is irreversible !</span>
 | 
			
		||||
 | 
			
		||||
			<button class="red" @click=${this.deleteAccount}>
 | 
			
		||||
				[Not implemented] Delete my Tilde Friends account
 | 
			
		||||
			</button>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-delete-account-btn', TfDeleteAccountButtonElement);
 | 
			
		||||
							
								
								
									
										71
									
								
								apps/user_settings/tf-identity-manager.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								apps/user_settings/tf-identity-manager.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,71 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
class TfIdentityManagerElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			ids: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.ids = [];
 | 
			
		||||
		this.load();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load() {
 | 
			
		||||
		this.ids = await tfrpc.rpc.getIdentities();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async createIdentity() {
 | 
			
		||||
		try {
 | 
			
		||||
			let id = await tfrpc.rpc.createID();
 | 
			
		||||
			alert('Successfully created: ' + id);
 | 
			
		||||
			await tfrpc.rpc.reload();
 | 
			
		||||
		} catch (err) {
 | 
			
		||||
			alert('Error creating identity: ' + err);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async exportIdentity(id) {
 | 
			
		||||
		alert('Your private key is:\n' + (await tfrpc.rpc.getPrivateKey(id)));
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		return html`
 | 
			
		||||
			<link rel="stylesheet" href="/static/tildefriends-v1.css"/>
 | 
			
		||||
			<style>
 | 
			
		||||
				.id-span {
 | 
			
		||||
					font-family: monospace;
 | 
			
		||||
					margin-left: 8px;
 | 
			
		||||
				}
 | 
			
		||||
			</style>
 | 
			
		||||
 | 
			
		||||
			<h4>Create a new identity</h4>
 | 
			
		||||
			<button id="create-id" class="green" @click=${this.createIdentity}>Create Identity</button>
 | 
			
		||||
 | 
			
		||||
			<h4>Import an SSB Identity from 12 BIP39 English Words</h4>
 | 
			
		||||
			<textarea id="add-id" style="width: 100%" rows="4"></textarea>
 | 
			
		||||
			<button class="green">[Not implemented] Import Identity</button>
 | 
			
		||||
 | 
			
		||||
			<h4>Warning !</h4>
 | 
			
		||||
			<strong>Anybody that has access to your private key can gain total access over your account.</strong>
 | 
			
		||||
			<br><br>
 | 
			
		||||
			Tilde Friends' contributors will never ask you for your private key !
 | 
			
		||||
 | 
			
		||||
			<ul>
 | 
			
		||||
			${this.ids.map(
 | 
			
		||||
				(id) =>
 | 
			
		||||
					html`
 | 
			
		||||
				<li>
 | 
			
		||||
					<button class="blue" @click=${() => this.exportIdentity(id)}>Export Identity</button>
 | 
			
		||||
					<button class="red">[Not implemented] Delete Identity</button>
 | 
			
		||||
					<span class="id-span">${id}</span>
 | 
			
		||||
				</li>`
 | 
			
		||||
			)}
 | 
			
		||||
			</ul>`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-identity-manager', TfIdentityManagerElement);
 | 
			
		||||
							
								
								
									
										68
									
								
								apps/user_settings/tf-password-form.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								apps/user_settings/tf-password-form.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,68 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
class TfPasswordFormElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			//selected: {type: String},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	/**
 | 
			
		||||
	 * Checks a password against different requirements
 | 
			
		||||
	 * @param {string} password the password to validate
 | 
			
		||||
	 * @returns
 | 
			
		||||
	 */
 | 
			
		||||
	validatePassword(password) {
 | 
			
		||||
		// TODO(tasiaiso)
 | 
			
		||||
		return true;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	submitPassword() {
 | 
			
		||||
		const currentPwd = this.shadowRoot.getElementById('current').value;
 | 
			
		||||
		const newPwd = this.shadowRoot.getElementById('new').value;
 | 
			
		||||
		const repeatPwd = this.shadowRoot.getElementById('Repeat').value;
 | 
			
		||||
 | 
			
		||||
		if (!(newPwd === repeatPwd)) {
 | 
			
		||||
			return;
 | 
			
		||||
		}
 | 
			
		||||
 | 
			
		||||
		// TODO
 | 
			
		||||
		// tfrpc.changePassword()
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		return html`
 | 
			
		||||
			<link rel="stylesheet" href="/static/tildefriends-v1.css"/>
 | 
			
		||||
 | 
			
		||||
			<style>
 | 
			
		||||
				.grid {
 | 
			
		||||
					display: grid;
 | 
			
		||||
					grid-template-columns: auto auto;
 | 
			
		||||
				}
 | 
			
		||||
			</style>
 | 
			
		||||
 | 
			
		||||
			<div class="grid">
 | 
			
		||||
				<label for="current">Current password:</label>
 | 
			
		||||
				<input type="password" id="current" name="current" autocomplete="current-password" />
 | 
			
		||||
 | 
			
		||||
				<label for="new">Enter new password:</label>
 | 
			
		||||
				<input type="password" id="new" name="new" autocomplete="new-password" />
 | 
			
		||||
 | 
			
		||||
				<label for="repeat">Repeat new password:</label>
 | 
			
		||||
				<input type="password" id="repeat" name="repeat" autocomplete="new-password" />
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<button @click=${this.submitPassword} class="red">
 | 
			
		||||
				[Not implemented] Change my password
 | 
			
		||||
			</button>
 | 
			
		||||
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-password-form', TfPasswordFormElement);
 | 
			
		||||
							
								
								
									
										40
									
								
								apps/user_settings/tf-theme-picker.js
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										40
									
								
								apps/user_settings/tf-theme-picker.js
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,40 @@
 | 
			
		||||
import {LitElement, html} from './lit-all.min.js';
 | 
			
		||||
import * as tfrpc from '/static/tfrpc.js';
 | 
			
		||||
 | 
			
		||||
class TfThemePickerElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			selected: {type: String},
 | 
			
		||||
			themes: {type: Array},
 | 
			
		||||
		};
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	constructor() {
 | 
			
		||||
		super();
 | 
			
		||||
		this.load();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	async load() {
 | 
			
		||||
		this.themes = await tfrpc.rpc.getThemes();
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	changed(event) {
 | 
			
		||||
		this.selected = event.srcElement.value;
 | 
			
		||||
		console.log('selected theme', this.selected);
 | 
			
		||||
		// TODO
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		return html`
 | 
			
		||||
			<link rel="stylesheet" href="/static/tildefriends-v1.css"/>
 | 
			
		||||
 | 
			
		||||
			<label for="theme">[Not implemented] Choose your theme:</label>
 | 
			
		||||
 | 
			
		||||
			<select name="theme" ?hidden=${!this.themes?.length} @change=${this.changed}>
 | 
			
		||||
				${(this.themes ?? []).map((id) => html`<option value=${id}>${id}</option>`)}
 | 
			
		||||
			</select>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-theme-picker', TfThemePickerElement);
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "👋",
 | 
			
		||||
  "previous": "&zFISmRDAv+SXFonfZ9/sHNhrmMe+poTU22gwZzuSkT4=.sha256"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "👋",
 | 
			
		||||
	"previous": "&zFISmRDAv+SXFonfZ9/sHNhrmMe+poTU22gwZzuSkT4=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -2,4 +2,4 @@ async function main() {
 | 
			
		||||
	await app.setDocument(utf8Decode(getFile('index.html')));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,23 +1,36 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html>
 | 
			
		||||
	<head>
 | 
			
		||||
		<meta charset="UTF-8">
 | 
			
		||||
		<meta name="viewport" content="width=device-width, initial-scale=1">
 | 
			
		||||
		<link rel="stylesheet" href="w3.css">
 | 
			
		||||
		<link rel="stylesheet" href="fontawesome.min.css">
 | 
			
		||||
		<link rel="stylesheet" href="regular.min.css">
 | 
			
		||||
		<link rel="stylesheet" href="solid.min.css">
 | 
			
		||||
		<link rel="stylesheet" href="brands.min.css">
 | 
			
		||||
		<meta charset="UTF-8" />
 | 
			
		||||
		<meta name="viewport" content="width=device-width, initial-scale=1" />
 | 
			
		||||
		<link rel="stylesheet" href="w3.css" />
 | 
			
		||||
		<link rel="stylesheet" href="fontawesome.min.css" />
 | 
			
		||||
		<link rel="stylesheet" href="regular.min.css" />
 | 
			
		||||
		<link rel="stylesheet" href="solid.min.css" />
 | 
			
		||||
		<link rel="stylesheet" href="brands.min.css" />
 | 
			
		||||
 | 
			
		||||
		<style>
 | 
			
		||||
			body,h1,h2,h3,h4,h5 {font-family: "Poppins", sans-serif}
 | 
			
		||||
			body {font-size: 16px;}
 | 
			
		||||
			img {margin-bottom: -8px;}
 | 
			
		||||
			.mySlides {display: none;}
 | 
			
		||||
			body,
 | 
			
		||||
			h1,
 | 
			
		||||
			h2,
 | 
			
		||||
			h3,
 | 
			
		||||
			h4,
 | 
			
		||||
			h5 {
 | 
			
		||||
				font-family: 'Poppins', sans-serif;
 | 
			
		||||
			}
 | 
			
		||||
			body {
 | 
			
		||||
				font-size: 16px;
 | 
			
		||||
			}
 | 
			
		||||
			img {
 | 
			
		||||
				margin-bottom: -8px;
 | 
			
		||||
			}
 | 
			
		||||
			.mySlides {
 | 
			
		||||
				display: none;
 | 
			
		||||
			}
 | 
			
		||||
		</style>
 | 
			
		||||
		<base target="_top">
 | 
			
		||||
		<base target="_top" />
 | 
			
		||||
	</head>
 | 
			
		||||
	<body class="w3-content w3-black" style="max-width:1500px;">
 | 
			
		||||
	<body class="w3-content w3-black" style="max-width: 1500px">
 | 
			
		||||
		<!-- The App Section -->
 | 
			
		||||
		<div class="w3-padding-64 w3-white">
 | 
			
		||||
			<div class="w3-row-padding">
 | 
			
		||||
@@ -25,41 +38,64 @@
 | 
			
		||||
					<h1 class="w3-jumbo">
 | 
			
		||||
						<b>😎 Tilde Friends</b>
 | 
			
		||||
					</h1>
 | 
			
		||||
					<h1 class="w3-xxlarge w3-text-green"><b>Make apps and friends from the comfort of your web browser.</b></h1>
 | 
			
		||||
					<p>Tilde Friends is a platform for building, running, and sharing web applications.</p>
 | 
			
		||||
					<p>Available for lots of devices:
 | 
			
		||||
					<h1 class="w3-xxlarge w3-text-green">
 | 
			
		||||
						<b>Make apps and friends from the comfort of your web browser.</b>
 | 
			
		||||
					</h1>
 | 
			
		||||
					<p>
 | 
			
		||||
						Tilde Friends is a platform for building, running, and sharing web
 | 
			
		||||
						applications.
 | 
			
		||||
					</p>
 | 
			
		||||
					<p>
 | 
			
		||||
						Available for lots of devices:
 | 
			
		||||
						<i class="fa-brands fa-linux w3-xlarge"></i>
 | 
			
		||||
						<i class="fa-brands fa-android w3-xlarge"></i>
 | 
			
		||||
						<i class="fa-brands fa-apple w3-xlarge"></i>
 | 
			
		||||
						<i class="fa fa-mobile-screen w3-xlarge"></i>
 | 
			
		||||
						<i class="fa-brands fa-windows w3-xlarge"></i>
 | 
			
		||||
					</p>
 | 
			
		||||
					<a class="w3-button w3-black w3-padding-large" href="https://www.tildefriends.net/~cory/releases/"><i class="fa fa-download"></i> Download</a>
 | 
			
		||||
					<a class="w3-button w3-black w3-padding-large" href="https://www.tildefriends.net/~cory/apps/"><i class="fa fa-link"></i> Try It</a>
 | 
			
		||||
					<a
 | 
			
		||||
						class="w3-button w3-black w3-padding-large"
 | 
			
		||||
						href="https://www.tildefriends.net/~cory/releases/"
 | 
			
		||||
						><i class="fa fa-download"></i> Download</a
 | 
			
		||||
					>
 | 
			
		||||
					<a
 | 
			
		||||
						class="w3-button w3-black w3-padding-large"
 | 
			
		||||
						href="https://www.tildefriends.net/~cory/apps/"
 | 
			
		||||
						><i class="fa fa-link"></i> Try It</a
 | 
			
		||||
					>
 | 
			
		||||
				</div>
 | 
			
		||||
				<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>
 | 
			
		||||
 | 
			
		||||
		<!-- SSB Section -->
 | 
			
		||||
		<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">
 | 
			
		||||
					<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 class="w3-col l8 m6" style="height: auto">
 | 
			
		||||
					<h1 class="w3-jumbo"><b>Built for Sharing</b></h1>
 | 
			
		||||
					<p>
 | 
			
		||||
						Tilde Friends participates in the <a href="https://scuttlebutt.nz/">Secure Scuttlebutt</a> distributed social network.
 | 
			
		||||
						Tilde Friends participates in the
 | 
			
		||||
						<a href="https://scuttlebutt.nz/">Secure Scuttlebutt</a> distributed
 | 
			
		||||
						social network.
 | 
			
		||||
					</p>
 | 
			
		||||
					<p>
 | 
			
		||||
						Share apps with friends.  Discover new apps made by enemies.  Post pictures of your coffee.  Or just lurk.
 | 
			
		||||
						Share apps with friends. Discover new apps made by enemies. Post
 | 
			
		||||
						pictures of your coffee. Or just lurk.
 | 
			
		||||
					</p>
 | 
			
		||||
					<p>
 | 
			
		||||
						The social network integration provides tools for connecting with other people world-wide
 | 
			
		||||
						while still allowing apps and everything to operate offline.
 | 
			
		||||
						The social network integration provides tools for connecting with
 | 
			
		||||
						other people world-wide while still allowing apps and everything to
 | 
			
		||||
						operate offline.
 | 
			
		||||
					</p>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
@@ -70,14 +106,16 @@
 | 
			
		||||
			<div class="w3-row-padding">
 | 
			
		||||
				<div class="w3-col l8 m6">
 | 
			
		||||
					<h1 class="w3-jumbo"><b>Edit Anything</b></h1>
 | 
			
		||||
					<i class="fa fa-pen-to-square w3-left w3-jumbo w3-text-gray" style="padding: 32px"></i>
 | 
			
		||||
					<i
 | 
			
		||||
						class="fa fa-pen-to-square w3-left w3-jumbo w3-text-gray"
 | 
			
		||||
						style="padding: 32px"
 | 
			
		||||
					></i>
 | 
			
		||||
					<p>
 | 
			
		||||
						See that <code><b>edit</b></code> link near the top left corner of this page?  It's there for
 | 
			
		||||
						every Tilde Friends app, so you can modify and see your changes right away.
 | 
			
		||||
					</p>
 | 
			
		||||
					<p>
 | 
			
		||||
						It's kind of like a wiki, but for code!
 | 
			
		||||
						See that <code><b>edit</b></code> link near the top left corner of
 | 
			
		||||
						this page? It's there for every Tilde Friends app, so you can modify
 | 
			
		||||
						and see your changes right away.
 | 
			
		||||
					</p>
 | 
			
		||||
					<p>It's kind of like a wiki, but for code!</p>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
		</div>
 | 
			
		||||
@@ -86,16 +124,22 @@
 | 
			
		||||
		<div class="w3-padding-64 w3-grey">
 | 
			
		||||
			<div class="w3-row-padding">
 | 
			
		||||
				<div class="w3-col">
 | 
			
		||||
					<h1 class="w3-jumbo" style="text-align: right"><b>Sandbox Security</b></h1>
 | 
			
		||||
					<i class="fa fa-road-barrier w3-right w3-jumbo w3-text-yellow" style="padding: 32px"></i>
 | 
			
		||||
					<h1 class="w3-jumbo" style="text-align: right">
 | 
			
		||||
						<b>Sandbox Security</b>
 | 
			
		||||
					</h1>
 | 
			
		||||
					<i
 | 
			
		||||
						class="fa fa-road-barrier w3-right w3-jumbo w3-text-yellow"
 | 
			
		||||
						style="padding: 32px"
 | 
			
		||||
					></i>
 | 
			
		||||
					<p>
 | 
			
		||||
						Tilde Friends tries to make sure apps can be trusted using similar techniques to how web
 | 
			
		||||
						browsers and operating systems do it.
 | 
			
		||||
						Tilde Friends tries to make sure apps can be trusted using similar
 | 
			
		||||
						techniques to how web browsers and operating systems do it.
 | 
			
		||||
					</p>
 | 
			
		||||
					<p>
 | 
			
		||||
						This is all a work in progress, and it varies by platform, so don't give it all your
 | 
			
		||||
						innermost secrets yet, but do kick its tires and
 | 
			
		||||
						<a href="mailto:cory@tildefriends.net">share</a> any surprises you find.
 | 
			
		||||
						This is all a work in progress, and it varies by platform, so don't
 | 
			
		||||
						give it all your innermost secrets yet, but do kick its tires and
 | 
			
		||||
						<a href="mailto:cory@tildefriends.net">share</a> any surprises you
 | 
			
		||||
						find.
 | 
			
		||||
					</p>
 | 
			
		||||
				</div>
 | 
			
		||||
			</div>
 | 
			
		||||
@@ -105,10 +149,16 @@
 | 
			
		||||
		<div class="w3-container w3-padding-64 w3-light-grey w3-center">
 | 
			
		||||
			<h1 class="w3-jumbo"><b>Trusted Technology</b></h1>
 | 
			
		||||
			<p>Tilde Friends is built using boring, trusted tech.</p>
 | 
			
		||||
			<p>Though of course for building Tilde Friends apps, you are free to use whatever fits.</p>
 | 
			
		||||
			<p>
 | 
			
		||||
				Though of course for building Tilde Friends apps, you are free to use
 | 
			
		||||
				whatever fits.
 | 
			
		||||
			</p>
 | 
			
		||||
 | 
			
		||||
			<div class="w3-row" style="margin-top:64px">
 | 
			
		||||
				<a href="https://en.wikipedia.org/wiki/C_(programming_language)" class="w3-col s3">
 | 
			
		||||
			<div class="w3-row" style="margin-top: 64px">
 | 
			
		||||
				<a
 | 
			
		||||
					href="https://en.wikipedia.org/wiki/C_(programming_language)"
 | 
			
		||||
					class="w3-col s3"
 | 
			
		||||
				>
 | 
			
		||||
					<i class="fa fa-c w3-text-blue w3-jumbo"></i>
 | 
			
		||||
					<p>C</p>
 | 
			
		||||
				</a>
 | 
			
		||||
@@ -126,7 +176,7 @@
 | 
			
		||||
				</a>
 | 
			
		||||
			</div>
 | 
			
		||||
 | 
			
		||||
			<div class="w3-row" style="margin-top:64px">
 | 
			
		||||
			<div class="w3-row" style="margin-top: 64px">
 | 
			
		||||
				<a href="https://www.zlib.net/" class="w3-col s3">
 | 
			
		||||
					<i class="fa fa-file-zipper w3-text-cyan w3-jumbo"></i>
 | 
			
		||||
					<p>zlib</p>
 | 
			
		||||
@@ -137,15 +187,18 @@
 | 
			
		||||
				</a>
 | 
			
		||||
				<a href="https://www.openssl.org/" class="w3-col s3">
 | 
			
		||||
					<i class="fa fa-shield-halved w3-text-green w3-jumbo"></i>
 | 
			
		||||
					<p>OpenSSL	</p>
 | 
			
		||||
					<p>OpenSSL</p>
 | 
			
		||||
				</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>
 | 
			
		||||
					<p>libbacktrace</p>
 | 
			
		||||
				</a>
 | 
			
		||||
			</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">
 | 
			
		||||
					<i class="fa fa-keyboard w3-text-indigo w3-jumbo"></i>
 | 
			
		||||
					<p>CodeMirror</p>
 | 
			
		||||
@@ -167,7 +220,10 @@
 | 
			
		||||
 | 
			
		||||
		<!-- Footer -->
 | 
			
		||||
		<footer class="w3-container w3-padding-32 w3-blue-grey w3-center w3-xlarge">
 | 
			
		||||
			<p class="w3-medium">This page and Tilde Friends itself was made by Cory mostly in coffee shops and a local pizza place.</p>
 | 
			
		||||
			<p class="w3-medium">
 | 
			
		||||
				This page and Tilde Friends itself was made by Cory mostly in coffee
 | 
			
		||||
				shops and a local pizza place.
 | 
			
		||||
			</p>
 | 
			
		||||
		</footer>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -1,5 +1,5 @@
 | 
			
		||||
{
 | 
			
		||||
  "type": "tildefriends-app",
 | 
			
		||||
  "emoji": "📝",
 | 
			
		||||
  "previous": "&/wl8HE2jZShRXTYEVYRrK3pjHwi41Wbxl9HoSJaQP6Y=.sha256"
 | 
			
		||||
}
 | 
			
		||||
	"type": "tildefriends-app",
 | 
			
		||||
	"emoji": "📝",
 | 
			
		||||
	"previous": "&DnfuAUGzzalSh9NgZXnzDc9Ru5aM0omfRJ4h27jYw4k=.sha256"
 | 
			
		||||
}
 | 
			
		||||
 
 | 
			
		||||
@@ -78,4 +78,4 @@ async function main() {
 | 
			
		||||
	await app.setDocument(utf8Decode(await getFile('index.html')));
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
main();
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -11,10 +11,13 @@ function markdown(md) {
 | 
			
		||||
		let node = event.node;
 | 
			
		||||
		if (event.entering) {
 | 
			
		||||
			if (node.destination?.startsWith('&')) {
 | 
			
		||||
				node.destination = '/' + node.destination + '/view?filename=' + node.firstChild?.literal;
 | 
			
		||||
				node.destination =
 | 
			
		||||
					'/' + node.destination + '/view?filename=' + node.firstChild?.literal;
 | 
			
		||||
			} else if (node.type === 'link') {
 | 
			
		||||
				if (node.destination.indexOf(':') == -1 &&
 | 
			
		||||
					node.destination.indexOf('/') == -1) {
 | 
			
		||||
				if (
 | 
			
		||||
					node.destination.indexOf(':') == -1 &&
 | 
			
		||||
					node.destination.indexOf('/') == -1
 | 
			
		||||
				) {
 | 
			
		||||
					node.destination = `${node.destination}`;
 | 
			
		||||
				}
 | 
			
		||||
			}
 | 
			
		||||
@@ -29,7 +32,9 @@ async function main() {
 | 
			
		||||
		let wiki_name = request.path.substring(0, slash);
 | 
			
		||||
		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 wiki;
 | 
			
		||||
		for (let w of Object.values(wikis)) {
 | 
			
		||||
@@ -40,7 +45,13 @@ async function main() {
 | 
			
		||||
		}
 | 
			
		||||
		let wiki_doc;
 | 
			
		||||
		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)) {
 | 
			
		||||
				if (w.name === wiki_doc_name && !w.tombstone) {
 | 
			
		||||
					wiki_doc = w;
 | 
			
		||||
@@ -70,4 +81,4 @@ async function main() {
 | 
			
		||||
		});
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
main();
 | 
			
		||||
main();
 | 
			
		||||
 
 | 
			
		||||
@@ -1,14 +1,17 @@
 | 
			
		||||
<!DOCTYPE html>
 | 
			
		||||
<!doctype html>
 | 
			
		||||
<html>
 | 
			
		||||
	<head>
 | 
			
		||||
		<base target="_top">
 | 
			
		||||
		<base target="_top" />
 | 
			
		||||
		<link rel="stylesheet" href="tildefriends.css" />
 | 
			
		||||
	</head>
 | 
			
		||||
	<body style="color: #fff">
 | 
			
		||||
	<body>
 | 
			
		||||
		<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-id-picker.js" type="module"></script>
 | 
			
		||||
		<script src="tf-wiki-doc.js" type="module"></script>
 | 
			
		||||
		<script src="tf-wiki-app.js" type="module"></script>
 | 
			
		||||
	</body>
 | 
			
		||||
</html>
 | 
			
		||||
</html>
 | 
			
		||||
 
 | 
			
		||||
@@ -5,6 +5,7 @@ class TfCollectionElement extends LitElement {
 | 
			
		||||
	static get properties() {
 | 
			
		||||
		return {
 | 
			
		||||
			whoami: {type: String},
 | 
			
		||||
			category: {type: String},
 | 
			
		||||
			collection: {type: Object},
 | 
			
		||||
			selected_id: {type: String},
 | 
			
		||||
			is_creating: {type: Boolean},
 | 
			
		||||
@@ -14,82 +15,103 @@ class TfCollectionElement extends LitElement {
 | 
			
		||||
 | 
			
		||||
	on_create(event) {
 | 
			
		||||
		let name = this.shadowRoot.getElementById('create_name').value;
 | 
			
		||||
		this.dispatchEvent(new CustomEvent('create', {
 | 
			
		||||
			bubbles: true,
 | 
			
		||||
			detail: {
 | 
			
		||||
				name: name,
 | 
			
		||||
			},
 | 
			
		||||
		}));
 | 
			
		||||
		this.dispatchEvent(
 | 
			
		||||
			new CustomEvent('create', {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				detail: {
 | 
			
		||||
					name: name,
 | 
			
		||||
				},
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
		this.is_creating = false;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	on_rename(event) {
 | 
			
		||||
		let id = this.shadowRoot.getElementById('select').value;
 | 
			
		||||
		let name = this.shadowRoot.getElementById('rename_name').value;
 | 
			
		||||
		this.dispatchEvent(new CustomEvent('rename', {
 | 
			
		||||
			bubbles: true,
 | 
			
		||||
			detail: {
 | 
			
		||||
				id: id,
 | 
			
		||||
				value: this.collection[id],
 | 
			
		||||
				name: name,
 | 
			
		||||
			},
 | 
			
		||||
		}));
 | 
			
		||||
		this.dispatchEvent(
 | 
			
		||||
			new CustomEvent('rename', {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				detail: {
 | 
			
		||||
					id: id,
 | 
			
		||||
					value: this.collection[id],
 | 
			
		||||
					name: name,
 | 
			
		||||
				},
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
		this.is_renaming = false;
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	on_tombstone(event) {
 | 
			
		||||
		let id = this.shadowRoot.getElementById('select').value;
 | 
			
		||||
		if (confirm(`Are you sure you want to delete '${this.collection[id].name}'?`)) {
 | 
			
		||||
			this.dispatchEvent(new CustomEvent('tombstone', {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				detail: {
 | 
			
		||||
					id: id,
 | 
			
		||||
					value: this.collection[id],
 | 
			
		||||
				},
 | 
			
		||||
			}));
 | 
			
		||||
		if (
 | 
			
		||||
			confirm(`Are you sure you want to delete '${this.collection[id].name}'?`)
 | 
			
		||||
		) {
 | 
			
		||||
			this.dispatchEvent(
 | 
			
		||||
				new CustomEvent('tombstone', {
 | 
			
		||||
					bubbles: true,
 | 
			
		||||
					detail: {
 | 
			
		||||
						id: id,
 | 
			
		||||
						value: this.collection[id],
 | 
			
		||||
					},
 | 
			
		||||
				})
 | 
			
		||||
			);
 | 
			
		||||
		}
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	on_selected(event) {
 | 
			
		||||
		let id = event.srcElement.value;
 | 
			
		||||
		this.selected_id = id != '' ? id : undefined;
 | 
			
		||||
		this.dispatchEvent(new CustomEvent('change', {
 | 
			
		||||
			bubbles: true,
 | 
			
		||||
			detail: {
 | 
			
		||||
				id: id,
 | 
			
		||||
				value: this.collection[id],
 | 
			
		||||
			},
 | 
			
		||||
		}));
 | 
			
		||||
		this.dispatchEvent(
 | 
			
		||||
			new CustomEvent('change', {
 | 
			
		||||
				bubbles: true,
 | 
			
		||||
				detail: {
 | 
			
		||||
					id: id,
 | 
			
		||||
					value: this.collection[id],
 | 
			
		||||
				},
 | 
			
		||||
			})
 | 
			
		||||
		);
 | 
			
		||||
	}
 | 
			
		||||
 | 
			
		||||
	render() {
 | 
			
		||||
		let self = this;
 | 
			
		||||
		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}>
 | 
			
		||||
					<option value="" ?selected=${this.selected_id === ''} disabled hidden>(select)</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>`)}
 | 
			
		||||
					<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>`
 | 
			
		||||
						)}
 | 
			
		||||
				</select>
 | 
			
		||||
				<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>
 | 
			
		||||
						<input type="text" id="rename_name"></input>
 | 
			
		||||
						<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>
 | 
			
		||||
				<button @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="yellow" @click=${() => (self.is_renaming = true)} ?disabled=${this.is_renaming || !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}>
 | 
			
		||||
					<label for="create_name">New ${this.type} name:</label>
 | 
			
		||||
					<input type="text" id="create_name"></input>
 | 
			
		||||
					<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>
 | 
			
		||||
				<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>
 | 
			
		||||
		`;
 | 
			
		||||
	}
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
customElements.define('tf-collection', TfCollectionElement);
 | 
			
		||||
customElements.define('tf-collection', TfCollectionElement);
 | 
			
		||||
 
 | 
			
		||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user