Compare commits

..

No commits in common. "main" and "dev_tasia" have entirely different histories.

4243 changed files with 1121364 additions and 44374 deletions

View File

@ -14,7 +14,7 @@ IndentWidth: 4
MaxEmptyLinesToKeep: 1 MaxEmptyLinesToKeep: 1
ObjCBlockIndentWidth: 4 ObjCBlockIndentWidth: 4
ObjCBreakBeforeNestedBlockParam: false ObjCBreakBeforeNestedBlockParam: false
SortIncludes: true SortIncludes: false
TabWidth: 4 TabWidth: 4
UseTab: Always UseTab: Always
... ...

View File

@ -1,2 +0,0 @@
# Add prettier to the project
41024ddb7961b04a5688bbc997cb74de6fab4763

View File

@ -1,37 +0,0 @@
name: Build Tilde Friends
run-name: ${{ gitea.actor }} running 🚀
on: [push]
jobs:
Build-All:
runs-on: ubuntu-latest
container:
valid_volumes: ['/opt/keys']
volumes:
- /opt/keys:/opt/keys
steps:
- name: check out code
uses: actions/checkout@v4
with:
submodules: true
- run: ln -s /opt/keys .keys
- name: Setup JDK
uses: actions/setup-java@v3
with:
java-version: '17'
distribution: 'temurin'
- name: Setup Android SDK
uses: android-actions/setup-android@v3
with:
packages: 'tools platform-tools build-tools;34.0.0 platforms;android-34 ndk;26.3.11579264'
- run: sudo apt update && sudo apt install -y doxygen graphviz mingw-w64 libgpgme11 gcc-aarch64-linux-gnu
- run: ANDROID_SDK=$HOME/.android/sdk make -j`nproc` all docs
- run: docker build .
- uses: actions/upload-artifact@v3
with:
path: |
out/TildeFriends-release.fdroid.apk
out/winrelease/tildefriends.standalone.exe
out/tildefriends-x86_64.AppImage
out/release/tildefriends.standalone
out/armrelease/tildefriends.standalone

18
.gitignore vendored
View File

@ -1,18 +1,4 @@
build/
*.core
db.*
deps/ios_toolchain/
deps/openssl/
dist/
.flatpak-builder
.keys .keys
logs/
**/node_modules
out out
repo/ **/node_modules
result db.*
*.swo
*.swp
tmp/
unsigned/
.zsign_cache/

31
.gitmodules vendored
View File

@ -1,31 +0,0 @@
[submodule "deps/zlib"]
path = deps/zlib
url = https://github.com/madler/zlib.git
[submodule "deps/libsodium"]
path = deps/libsodium
url = https://github.com/jedisct1/libsodium.git
[submodule "deps/quickjs"]
path = deps/quickjs
url = https://github.com/bellard/quickjs.git
[submodule "deps/crypt_blowfish"]
path = deps/crypt_blowfish
url = https://github.com/openwall/crypt_blowfish.git
[submodule "deps/libbacktrace"]
path = deps/libbacktrace
url = https://github.com/ianlancetaylor/libbacktrace.git
[submodule "deps/libuv"]
path = deps/libuv
url = https://github.com/libuv/libuv.git
[submodule "deps/picohttpparser"]
path = deps/picohttpparser
url = https://github.com/h2o/picohttpparser.git
[submodule "deps/openssl_src"]
path = deps/openssl_src
url = https://github.com/openssl/openssl.git
shallow = true
[submodule "deps/c-ares"]
path = deps/c-ares
url = https://github.com/c-ares/c-ares.git
[submodule "docs"]
path = docs
url = https://dev.tildefriends.net/cory/tildefriends.wiki.git

View File

@ -1,15 +0,0 @@
node_modules
src
deps
.clang-format
flake.lock
# Minified files
**/*.min.css
**/*.min.js
**/leaflet.*
**/commonmark*
**/w3.css
apps/ssb/tribute.esm.js
apps/api/app.js
**/emojis.json

View File

@ -1,5 +0,0 @@
trailingComma: 'es5'
useTabs: true
semi: true
singleQuote: true
bracketSpacing: false

View File

@ -1,37 +0,0 @@
# 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`.

2815
Doxyfile

File diff suppressed because it is too large Load Diff

View File

@ -3,27 +3,9 @@
MAKEFLAGS += --warn-undefined-variables MAKEFLAGS += --warn-undefined-variables
MAKEFLAGS += --no-builtin-rules MAKEFLAGS += --no-builtin-rules
## == Tilde Friends build. == VERSION_CODE := 16
## VERSION_NUMBER := 0.0.16-wip
## This is a list of all supported build targets. VERSION_NAME := Medium English breakfast tea.
##
## Consider passing -j$(nproc) or adding it to your $MAKEFLAGS to build in
## parallel (faster).
##
## Useful variables to override:
## CC := Compiler.
## AS := Assembler.
## LD := Linker.
## ANDROID_SDK := Path to the Android SDK.
VERSION_CODE := 32
VERSION_NUMBER := 0.0.27-wip
VERSION_NAME := This program kills fascists.
SQLITE_URL := https://www.sqlite.org/2025/sqlite-amalgamation-3480000.zip
BUNDLETOOL_URL := https://github.com/google/bundletool/releases/download/1.17.0/bundletool-all-1.17.0.jar
APPIMAGETOOL_URL := https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage
APPIMAGETOOL_MD5 := e989fadfc4d685fd3d6aeeb9b525d74d out/appimagetool
PROJECT = tildefriends PROJECT = tildefriends
BUILD_DIR ?= out BUILD_DIR ?= out
@ -31,13 +13,6 @@ UNAME_S := $(shell uname -s)
UNAME_M := $(shell uname -m) UNAME_M := $(shell uname -m)
ANDROID_SDK ?= ~/Android/Sdk ANDROID_SDK ?= ~/Android/Sdk
BUNDLETOOL = out/bundletool.jar
HAVE_WIN := 0
HAVE_CROSS_AARCH64 := 0
export SOURCE_DATE_EPOCH=1
export TZ=UTC
ifeq ($(UNAME_S),Darwin) ifeq ($(UNAME_S),Darwin)
BUILD_TYPES := macosdebug macosrelease iosdebug iosrelease iossimdebug iossimrelease BUILD_TYPES := macosdebug macosrelease iosdebug iosrelease iossimdebug iossimrelease
@ -46,16 +21,12 @@ BUILD_TYPES := debug release
HAVE_ANDROID = $(if $(shell which $(ANDROID_SDK)/platform-tools/adb),1,0) HAVE_ANDROID = $(if $(shell which $(ANDROID_SDK)/platform-tools/adb),1,0)
HAVE_LINUX_IOS = $(if $(shell which deps/ios_toolchain/target/bin deps/ios_toolchain/target/bin/arm-apple-darwin11-clang),1,0) HAVE_LINUX_IOS = $(if $(shell which deps/ios_toolchain/target/bin deps/ios_toolchain/target/bin/arm-apple-darwin11-clang),1,0)
HAVE_WIN = $(if $(shell which x86_64-w64-mingw32-gcc-win32),1,0) HAVE_WIN = $(if $(shell which x86_64-w64-mingw32-gcc-win32),1,0)
ifneq ($(UNAME_M),aarch64)
HAVE_CROSS_AARCH64 = $(if $(shell which aarch64-linux-gnu-gcc),1,0)
endif
else ifeq ($(UNAME_S),Haiku) else ifeq ($(UNAME_S),Haiku)
BUILD_TYPES := debug release BUILD_TYPES := debug release
CFLAGS += -Dstatic_assert=_Static_assert CFLAGS += -Dstatic_assert=_Static_assert
LDFLAGS += \ LDFLAGS += \
-lbsd \ -lbsd \
-lnetwork \ -lnetwork
-Wno-stringop-overflow
else ifeq ($(UNAME_S),OpenBSD) else ifeq ($(UNAME_S),OpenBSD)
BUILD_TYPES := debug release BUILD_TYPES := debug release
CFLAGS += \ CFLAGS += \
@ -65,6 +36,7 @@ LDFLAGS += \
-lc++abi -lc++abi
HAVE_ANDROID := 0 HAVE_ANDROID := 0
HAVE_LINUX_IOS := 0 HAVE_LINUX_IOS := 0
HAVE_WIN := 0
else else
$(error Unexpected host platform $(UNAME_S).) $(error Unexpected host platform $(UNAME_S).)
endif endif
@ -74,23 +46,18 @@ CFLAGS += \
-Wall \ -Wall \
-Wextra \ -Wextra \
-Wno-unused-parameter \ -Wno-unused-parameter \
-Wno-unknown-warning-option \
-MMD \ -MMD \
-MP \ -MP \
-ffunction-sections \ -ffunction-sections \
-fdata-sections \ -fdata-sections \
-fno-exceptions \ -fno-exceptions \
-g -g
LDFLAGS += \
-Wno-attributes \
-Wno-aggressive-loop-optimizations \
-flto=auto
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_MIN_SDK_VERSION := 24 ANDROID_MIN_SDK_VERSION := 24
ANDROID_TARGET_SDK_VERSION := 34 ANDROID_TARGET_SDK_VERSION := 34
ANDROID_BUILD_TOOLS := $(ANDROID_SDK)/build-tools/34.0.0
ANDROID_PLATFORM := $(ANDROID_SDK)/platforms/android-$(ANDROID_TARGET_SDK_VERSION)
ANDROID_NDK ?= $(ANDROID_SDK)/ndk/26.3.11579264
ANDROID_ARMV7A_TARGETS := \ ANDROID_ARMV7A_TARGETS := \
out/androiddebug-armv7a/tildefriends \ out/androiddebug-armv7a/tildefriends \
@ -119,7 +86,7 @@ BUILD_TYPES += \
androidrelease-x86 \ androidrelease-x86 \
androiddebug-x86_64 \ androiddebug-x86_64 \
androidrelease-x86_64 androidrelease-x86_64
all: out/TildeFriends-arm-debug.apk out/TildeFriends-arm-release.apk out/TildeFriends-x86-debug.apk out/TildeFriends-x86-release.apk out/TildeFriends-release.fdroid.apk all: out/TildeFriends-arm-debug.apk out/TildeFriends-arm-release.apk out/TildeFriends-x86-debug.apk out/TildeFriends-x86-release.apk
endif endif
WINDOWS_TARGETS := \ WINDOWS_TARGETS := \
@ -127,14 +94,6 @@ WINDOWS_TARGETS := \
out/winrelease/tildefriends.exe out/winrelease/tildefriends.exe
ifeq ($(HAVE_WIN),1) ifeq ($(HAVE_WIN),1)
BUILD_TYPES += windebug winrelease BUILD_TYPES += windebug winrelease
all: out/windebug/tildefriends.standalone.exe out/winrelease/tildefriends.standalone.exe
endif
AARCH64_TARGETS := \
out/armdebug/tildefriends \
out/armrelease/tildefriends
ifeq ($(HAVE_CROSS_AARCH64),1)
BUILD_TYPES += armdebug armrelease
endif endif
LINUX_TARGETS := \ LINUX_TARGETS := \
@ -161,9 +120,6 @@ all: $(IOS_APPS) \
out/tildefriends-iossimdebug.app/tildefriends \ out/tildefriends-iossimdebug.app/tildefriends \
out/tildefriends-iossimrelease.app/tildefriends out/tildefriends-iossimrelease.app/tildefriends
endif endif
ifeq ($(HAVE_CROSS_AARCH64),1)
all: out/armrelease/tildefriends.standalone
endif
DEBUG_TARGETS := \ DEBUG_TARGETS := \
out/debug/tildefriends \ out/debug/tildefriends \
@ -174,8 +130,7 @@ DEBUG_TARGETS := \
out/androiddebug/tildefriends \ out/androiddebug/tildefriends \
out/androiddebug-armv7a/tildefriends \ out/androiddebug-armv7a/tildefriends \
out/androiddebug-x86_64/tildefriends \ out/androiddebug-x86_64/tildefriends \
out/androiddebug-x86/tildefriends \ out/androiddebug-x86/tildefriends
out/armdebug/tildefriends
RELEASE_TARGETS := \ RELEASE_TARGETS := \
out/release/tildefriends \ out/release/tildefriends \
out/winrelease/tildefriends.exe \ out/winrelease/tildefriends.exe \
@ -185,38 +140,27 @@ RELEASE_TARGETS := \
out/androidrelease/tildefriends \ out/androidrelease/tildefriends \
out/androidrelease-armv7a/tildefriends \ out/androidrelease-armv7a/tildefriends \
out/androidrelease-x86_64/tildefriends \ out/androidrelease-x86_64/tildefriends \
out/androidrelease-x86/tildefriends \ out/androidrelease-x86/tildefriends
out/armrelease/tildefriends
ALL_TARGETS = $(DEBUG_TARGETS) $(RELEASE_TARGETS) ALL_TARGETS = $(DEBUG_TARGETS) $(RELEASE_TARGETS)
ANDROID_RELEASE_TARGETS := $(filter-out $(DEBUG_TARGETS),$(ANDROID_TARGETS)) ANDROID_RELEASE_TARGETS := $(filter-out $(DEBUG_TARGETS),$(ANDROID_TARGETS))
NONANDROID_RELEASE_TARGETS := $(filter-out $(ANDROID_ARM64_TARGETS),$(RELEASE_TARGETS)) NONANDROID_RELEASE_TARGETS := $(filter-out $(ANDROID_ARM64_TARGETS),$(RELEASE_TARGETS))
NONANDROID_TARGETS := $(filter-out $(ANDROID_TARGETS),$(ALL_TARGETS)) NONANDROID_TARGETS := $(filter-out $(ANDROID_TARGETS),$(ALL_TARGETS))
NONMACOS_TARGETS := $(filter-out $(MACOS_TARGETS) $(IOS_TARGETS) $(IOSSIM_TARGETS),$(ALL_TARGETS)) NONMACOS_TARGETS := $(filter-out $(MACOS_TARGETS) $(IOS_TARGETS) $(IOSSIM_TARGETS),$(ALL_TARGETS))
DEADSTRIP_TARGETS := $(filter-out $(ANDROID_TARGETS),$(NONMACOS_TARGETS))
ifneq ($(UNAME_S),OpenBSD)
$(NONMACOS_TARGETS): LDFLAGS += -static-libgcc
endif
$(NONANDROID_TARGETS): CFLAGS += -fno-omit-frame-pointer $(NONANDROID_TARGETS): CFLAGS += -fno-omit-frame-pointer
$(filter-out $(WINDOWS_TARGETS),$(ALL_TARGETS)): LDFLAGS += -rdynamic $(filter-out $(ANDROID_TARGETS) $(WINDOWS_TARGETS),$(ALL_TARGETS)): LDFLAGS += -rdynamic
$(ANDROID_TARGETS): CFLAGS += \ $(ANDROID_TARGETS): CFLAGS += \
--sysroot $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/sysroot \ --sysroot $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/sysroot \
-fPIC \ -fPIC \
-fdebug-compilation-dir . \ -fdebug-compilation-dir . \
-fomit-frame-pointer \ -fomit-frame-pointer \
-fno-asynchronous-unwind-tables \ -fno-asynchronous-unwind-tables \
-funwind-tables \ -funwind-tables
-Wno-unknown-warning-option
$(ANDROID_TARGETS): LDFLAGS += --sysroot $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/sysroot -fPIC $(ANDROID_TARGETS): LDFLAGS += --sysroot $(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/sysroot -fPIC
$(DEBUG_TARGETS): CFLAGS += -DDEBUG -Og $(DEBUG_TARGETS): CFLAGS += -DDEBUG -Og
$(DEBUG_TARGETS): LDFLAGS += -Og $(RELEASE_TARGETS): CFLAGS += -DNDEBUG
$(RELEASE_TARGETS): CFLAGS += \ $(NONANDROID_RELEASE_TARGETS): CFLAGS += -O3
-DNDEBUG \
-flto
$(ANDROID_RELEASE_TARGETS): CFLAGS += -Oz $(ANDROID_RELEASE_TARGETS): CFLAGS += -Oz
$(ANDROID_RELEASE_TARGETS): LDFLAGS += -Oz
$(NONANDROID_RELEASE_TARGETS): CFLAGS += -Os
$(NONANDROID_RELEASE_TARGETS): LDFLAGS += -Os
$(WINDOWS_TARGETS): CC = x86_64-w64-mingw32-gcc-win32 $(WINDOWS_TARGETS): CC = x86_64-w64-mingw32-gcc-win32
$(WINDOWS_TARGETS): AS = $(CC) $(WINDOWS_TARGETS): AS = $(CC)
$(WINDOWS_TARGETS): CFLAGS += \ $(WINDOWS_TARGETS): CFLAGS += \
@ -228,10 +172,6 @@ $(WINDOWS_TARGETS): LDFLAGS += \
-static \ -static \
-lm \ -lm \
-Ldeps/openssl/mingw64/usr/local/lib -Ldeps/openssl/mingw64/usr/local/lib
$(AARCH64_TARGETS): CC = aarch64-linux-gnu-gcc
$(AARCH64_TARGETS): AS = $(CC)
$(AARCH64_TARGETS): CFLAGS += -Ideps/openssl/Linux/aarch64/usr/local/include
$(AARCH64_TARGETS): LDFLAGS += -Ldeps/openssl/Linux/aarch64/usr/local/lib
ifeq ($(UNAME_S),Darwin) ifeq ($(UNAME_S),Darwin)
$(MACOS_TARGETS): CC = xcrun clang $(MACOS_TARGETS): CC = xcrun clang
$(IOS_TARGETS): IOS_SYSROOT := $(shell xcrun --sdk iphoneos --show-sdk-path) $(IOS_TARGETS): IOS_SYSROOT := $(shell xcrun --sdk iphoneos --show-sdk-path)
@ -239,8 +179,7 @@ $(IOS_TARGETS): CC = xcrun --sdk iphoneos clang -isysroot $(IOS_SYSROOT) -arch a
$(IOSSIM_TARGETS): IOSSIM_SYSROOT := $(shell xcrun --sdk iphonesimulator --show-sdk-path) $(IOSSIM_TARGETS): IOSSIM_SYSROOT := $(shell xcrun --sdk iphonesimulator --show-sdk-path)
$(IOSSIM_TARGETS): CC = xcrun --sdk iphonesimulator clang -isysroot $(IOSSIM_SYSROOT) -arch x86_64 $(IOSSIM_TARGETS): CC = xcrun --sdk iphonesimulator clang -isysroot $(IOSSIM_SYSROOT) -arch x86_64
else ifeq ($(UNAME_S),Linux) else ifeq ($(UNAME_S),Linux)
$(IOS_TARGETS): CFLAGS += -isysroot deps/ios_toolchain/target/SDKs/iPhoneOS18.2.sdk -arch arm64 $(IOS_TARGETS): IOS_SYSROOT := deps/iPhoneOS17.0.sdk
$(IOS_TARGETS): LDFLAGS += -isysroot deps/ios_toolchain/target/SDKs/iPhoneOS18.2.sdk
$(IOS_TARGETS): CC = PATH=$$PATH:deps/ios_toolchain/target/bin deps/ios_toolchain/target/bin/arm-apple-darwin11-clang $(IOS_TARGETS): CC = PATH=$$PATH:deps/ios_toolchain/target/bin deps/ios_toolchain/target/bin/arm-apple-darwin11-clang
endif endif
$(ANDROID_X86_64_TARGETS): ANDROID_NDK_TARGET_TRIPLE := x86_64-linux-android $(ANDROID_X86_64_TARGETS): ANDROID_NDK_TARGET_TRIPLE := x86_64-linux-android
@ -262,39 +201,27 @@ $(ANDROID_X86_TARGETS): LDFLAGS += -Ldeps/openssl/android/x86/usr/local/lib
$(ANDROID_X86_64_TARGETS): CFLAGS += -Ideps/openssl/android/x86_64/usr/local/include $(ANDROID_X86_64_TARGETS): CFLAGS += -Ideps/openssl/android/x86_64/usr/local/include
$(ANDROID_X86_64_TARGETS): LDFLAGS += -Ldeps/openssl/android/x86_64/usr/local/lib $(ANDROID_X86_64_TARGETS): LDFLAGS += -Ldeps/openssl/android/x86_64/usr/local/lib
$(NONMACOS_TARGETS): CFLAGS += -Wno-cast-function-type $(NONMACOS_TARGETS): CFLAGS += -Wno-cast-function-type
$(DEADSTRIP_TARGETS): LDFLAGS += -Wl,--gc-sections $(NONMACOS_TARGETS): LDFLAGS += -Wl,--gc-sections
$(IOS_TARGETS): CFLAGS += -miphoneos-version-min=9.0 $(IOS_TARGETS): CFLAGS += -mios-version-min=9.0 -Ideps/openssl/ios/ios64-xcrun/usr/local/include
$(IOS_TARGETS): LDFLAGS += -miphoneos-version-min=9.0
ifeq ($(UNAME_S),Darwin)
$(IOS_TARGETS): CFLAGS += -Ideps/openssl/ios/ios64-xcrun/usr/local/include
$(IOS_TARGETS): LDFLAGS += -Ldeps/openssl/ios/ios64-xcrun/usr/local/lib $(IOS_TARGETS): LDFLAGS += -Ldeps/openssl/ios/ios64-xcrun/usr/local/lib
else
$(IOS_TARGETS): CFLAGS += -Ideps/openssl/$(UNAME_S)/ios64-cross/usr/local/include
$(IOS_TARGETS): LDFLAGS += -Ldeps/openssl/$(UNAME_S)/ios64-cross/usr/local/lib
endif
$(IOSSIM_TARGETS): CFLAGS += -Ideps/openssl/ios/iossimulator-xcrun/usr/local/include $(IOSSIM_TARGETS): CFLAGS += -Ideps/openssl/ios/iossimulator-xcrun/usr/local/include
$(IOSSIM_TARGETS): LDFLAGS += -Ldeps/openssl/ios/iossimulator-xcrun/usr/local/lib $(IOSSIM_TARGETS): LDFLAGS += -Ldeps/openssl/ios/iossimulator-xcrun/usr/local/lib
$(LINUX_TARGETS) $(MACOS_TARGETS): CFLAGS += -Ideps/openssl/$(UNAME_S)/$(UNAME_M)/usr/local/include
$(LINUX_TARGETS) $(MACOS_TARGETS): LDFLAGS += -Ldeps/openssl/$(UNAME_S)/$(UNAME_M)/usr/local/lib
ifeq ($(UNAME_M),x86_64) ifeq ($(UNAME_M),x86_64)
ifeq ($(UNAME_S),Linux)
all: appimage out/release/tildefriends.standalone
endif
ifneq ($(UNAME_S),Haiku) ifneq ($(UNAME_S),Haiku)
out/debug/tildefriends: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common
out/debug/tildefriends: LDFLAGS += -fsanitize=address -fsanitize=undefined debug: LDFLAGS += -fsanitize=address -fsanitize=undefined
endif endif
endif endif
ifeq ($(UNAME_M),aarch64) ifeq ($(UNAME_M),aarch64)
out/debug/tildefriends: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common debug: CFLAGS += -fsanitize=address -fsanitize=undefined -fno-common
out/debug/tildefriends: LDFLAGS += -fsanitize=address -fsanitize=undefined debug: LDFLAGS += -fsanitize=address -fsanitize=undefined
endif endif
get_objs = \ get_objs = \
$(foreach build_type,$(BUILD_TYPES),$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)))))) \ $(foreach build_type,$(BUILD_TYPES),$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)))))) \
$(foreach build_type,debug release armdebug armrelease,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix))))) \ $(foreach build_type,debug release,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix))))) \
$(foreach build_type,windebug winrelease,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_win))))) \ $(foreach build_type,windebug winrelease,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_win))))) \
$(foreach build_type,androiddebug androidrelease androiddebug-x86 androidrelease-x86 androiddebug-x86_64 androidrelease-x86_64 androiddebug-armv7a androiddebug-armv7a,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_android))))) \ $(foreach build_type,androiddebug androidrelease androiddebug-x86 androidrelease-x86 androiddebug-x86_64 androidrelease-x86_64 androiddebug-armv7a androiddebug-armv7a,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_android))))) \
$(foreach build_type,androiddebug androidrelease androiddebug-x86 androidrelease-x86 androiddebug-x86_64 androidrelease-x86_64 androiddebug-armv7a androidrelease-armv7a,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix))))) \ $(foreach build_type,androiddebug androidrelease androiddebug-x86 androidrelease-x86 androiddebug-x86_64 androidrelease-x86_64 androiddebug-armv7a androidrelease-armv7a,$(addprefix $(BUILD_DIR)/$(build_type)/,$(addsuffix .o,$(basename $(value $(1)_unix))))) \
@ -307,8 +234,6 @@ APP_SOURCES_ios := $(wildcard src/*.m)
APP_OBJS := $(call get_objs,APP_SOURCES) APP_OBJS := $(call get_objs,APP_SOURCES)
$(APP_OBJS): CFLAGS += \ $(APP_OBJS): CFLAGS += \
-Ideps/base64c/include \ -Ideps/base64c/include \
-Ideps/c-ares/include \
-Ideps/c-ares_config \
-Ideps/crypt_blowfish \ -Ideps/crypt_blowfish \
-Ideps/libbacktrace \ -Ideps/libbacktrace \
-Ideps/libsodium \ -Ideps/libsodium \
@ -320,6 +245,7 @@ $(APP_OBJS): CFLAGS += \
-Ideps/quickjs \ -Ideps/quickjs \
-Ideps/sqlite \ -Ideps/sqlite \
-Ideps/valgrind \ -Ideps/valgrind \
-Ideps/xopt \
-Wdouble-promotion \ -Wdouble-promotion \
-Werror -Werror
ifeq ($(UNAME_M),x86_64) ifeq ($(UNAME_M),x86_64)
@ -327,108 +253,6 @@ $(filter-out $(BUILD_DIR)/android% $(BUILD_DIR)/macos% $(BUILD_DIR)/ios%,$(APP_O
-fanalyzer -fanalyzer
endif endif
ARES_SOURCES := \
deps/c-ares/src/lib/ares_addrinfo2hostent.c \
deps/c-ares/src/lib/ares_addrinfo_localhost.c \
deps/c-ares/src/lib/ares_android.c \
deps/c-ares/src/lib/ares_cancel.c \
deps/c-ares/src/lib/ares_close_sockets.c \
deps/c-ares/src/lib/ares_conn.c \
deps/c-ares/src/lib/ares_cookie.c \
deps/c-ares/src/lib/ares_data.c \
deps/c-ares/src/lib/ares_destroy.c \
deps/c-ares/src/lib/ares_free_hostent.c \
deps/c-ares/src/lib/ares_free_string.c \
deps/c-ares/src/lib/ares_freeaddrinfo.c \
deps/c-ares/src/lib/ares_getaddrinfo.c \
deps/c-ares/src/lib/ares_getenv.c \
deps/c-ares/src/lib/ares_gethostbyaddr.c \
deps/c-ares/src/lib/ares_gethostbyname.c \
deps/c-ares/src/lib/ares_getnameinfo.c \
deps/c-ares/src/lib/ares_hosts_file.c \
deps/c-ares/src/lib/ares_init.c \
deps/c-ares/src/lib/ares_library_init.c \
deps/c-ares/src/lib/ares_metrics.c \
deps/c-ares/src/lib/ares_options.c \
deps/c-ares/src/lib/ares_parse_into_addrinfo.c \
deps/c-ares/src/lib/ares_process.c \
deps/c-ares/src/lib/ares_qcache.c \
deps/c-ares/src/lib/ares_query.c \
deps/c-ares/src/lib/ares_search.c \
deps/c-ares/src/lib/ares_send.c \
deps/c-ares/src/lib/ares_set_socket_functions.c \
deps/c-ares/src/lib/ares_socket.c \
deps/c-ares/src/lib/ares_sortaddrinfo.c \
deps/c-ares/src/lib/ares_strerror.c \
deps/c-ares/src/lib/ares_sysconfig.c \
deps/c-ares/src/lib/ares_sysconfig_files.c \
deps/c-ares/src/lib/ares_sysconfig_mac.c \
deps/c-ares/src/lib/ares_sysconfig_win.c \
deps/c-ares/src/lib/ares_update_servers.c \
deps/c-ares/src/lib/ares_version.c \
deps/c-ares/src/lib/dsa/ares_array.c \
deps/c-ares/src/lib/dsa/ares_htable.c \
deps/c-ares/src/lib/dsa/ares_htable_asvp.c \
deps/c-ares/src/lib/dsa/ares_htable_dict.c \
deps/c-ares/src/lib/dsa/ares_htable_strvp.c \
deps/c-ares/src/lib/dsa/ares_htable_szvp.c \
deps/c-ares/src/lib/dsa/ares_htable_vpvp.c \
deps/c-ares/src/lib/dsa/ares_llist.c \
deps/c-ares/src/lib/dsa/ares_slist.c \
deps/c-ares/src/lib/event/ares_event_configchg.c \
deps/c-ares/src/lib/event/ares_event_epoll.c \
deps/c-ares/src/lib/event/ares_event_kqueue.c \
deps/c-ares/src/lib/event/ares_event_poll.c \
deps/c-ares/src/lib/event/ares_event_select.c \
deps/c-ares/src/lib/event/ares_event_thread.c \
deps/c-ares/src/lib/event/ares_event_wake_pipe.c \
deps/c-ares/src/lib/event/ares_event_win32.c \
deps/c-ares/src/lib/inet_net_pton.c \
deps/c-ares/src/lib/inet_ntop.c \
deps/c-ares/src/lib/legacy/ares_create_query.c \
deps/c-ares/src/lib/legacy/ares_expand_name.c \
deps/c-ares/src/lib/legacy/ares_expand_string.c \
deps/c-ares/src/lib/legacy/ares_fds.c \
deps/c-ares/src/lib/legacy/ares_getsock.c \
deps/c-ares/src/lib/legacy/ares_parse_a_reply.c \
deps/c-ares/src/lib/legacy/ares_parse_aaaa_reply.c \
deps/c-ares/src/lib/legacy/ares_parse_caa_reply.c \
deps/c-ares/src/lib/legacy/ares_parse_mx_reply.c \
deps/c-ares/src/lib/legacy/ares_parse_naptr_reply.c \
deps/c-ares/src/lib/legacy/ares_parse_ns_reply.c \
deps/c-ares/src/lib/legacy/ares_parse_ptr_reply.c \
deps/c-ares/src/lib/legacy/ares_parse_soa_reply.c \
deps/c-ares/src/lib/legacy/ares_parse_srv_reply.c \
deps/c-ares/src/lib/legacy/ares_parse_txt_reply.c \
deps/c-ares/src/lib/legacy/ares_parse_uri_reply.c \
deps/c-ares/src/lib/record/ares_dns_mapping.c \
deps/c-ares/src/lib/record/ares_dns_multistring.c \
deps/c-ares/src/lib/record/ares_dns_name.c \
deps/c-ares/src/lib/record/ares_dns_parse.c \
deps/c-ares/src/lib/record/ares_dns_record.c \
deps/c-ares/src/lib/record/ares_dns_write.c \
deps/c-ares/src/lib/str/ares_buf.c \
deps/c-ares/src/lib/str/ares_str.c \
deps/c-ares/src/lib/str/ares_strsplit.c \
deps/c-ares/src/lib/util/ares_iface_ips.c \
deps/c-ares/src/lib/util/ares_math.c \
deps/c-ares/src/lib/util/ares_rand.c \
deps/c-ares/src/lib/util/ares_threads.c \
deps/c-ares/src/lib/util/ares_timeval.c \
deps/c-ares/src/lib/util/ares_uri.c \
deps/c-ares/src/lib/windows_port.c \
deps/c-ares/src/lib/ares_timeout.c
ARES_OBJS := $(call get_objs,ARES_SOURCES)
$(ARES_OBJS): CFLAGS += \
-Ideps/c-ares/include \
-Ideps/c-ares/src/lib \
-Ideps/c-ares/src/lib/include \
-Ideps/c-ares_config/ \
-D_GNU_SOURCE \
-Wno-unused-function \
-Wno-deprecated-declarations \
-Wno-unused-result
BLOWFISH_SOURCES := \ BLOWFISH_SOURCES := \
deps/crypt_blowfish/crypt_blowfish.c \ deps/crypt_blowfish/crypt_blowfish.c \
deps/crypt_blowfish/crypt_gensalt.c \ deps/crypt_blowfish/crypt_gensalt.c \
@ -556,17 +380,10 @@ $(UV_OBJS): CFLAGS += \
-Wno-incompatible-pointer-types \ -Wno-incompatible-pointer-types \
-Wno-maybe-uninitialized \ -Wno-maybe-uninitialized \
-Wno-sign-compare \ -Wno-sign-compare \
-Wno-unknown-attributes \
-Wno-unused-but-set-parameter \ -Wno-unused-but-set-parameter \
-Wno-unused-but-set-variable \ -Wno-unused-but-set-variable \
-Wno-unused-result \ -Wno-unused-result \
-Wno-unused-variable \ -Wno-unused-variable
-Wno-nonnull
$(UV_OBJS): CFLAGS += -fno-lto
$(filter out/win%,$(UV_OBJS)): \
CFLAGS += \
-Wno-cast-function-type \
-Wno-missing-braces
ifeq ($(UNAME_S),Linux) ifeq ($(UNAME_S),Linux)
$(UV_OBJS): CFLAGS += \ $(UV_OBJS): CFLAGS += \
-D_GNU_SOURCE -D_GNU_SOURCE
@ -680,6 +497,18 @@ $(SQLITE_OBJS): CFLAGS += \
-Wno-unused-function \ -Wno-unused-function \
-Wno-unused-variable -Wno-unused-variable
XOPT_SOURCES := deps/xopt/xopt.c
XOPT_OBJS := $(call get_objs,XOPT_SOURCES)
$(filter $(BUILD_DIR)/win%,$(XOPT_OBJS)): CFLAGS += \
-DHAVE_SNPRINTF \
-DHAVE_VSNPRINTF \
-DHAVE_VASNPRINTF \
-DHAVE_VASPRINTF \
-Dvsnprintf=rpl_vsnprintf
$(XOPT_OBJS): CFLAGS += \
-Wno-implicit-const-int-float-conversion \
-Wno-pointer-to-int-cast
QUICKJS_SOURCES := \ QUICKJS_SOURCES := \
deps/quickjs/cutils.c \ deps/quickjs/cutils.c \
deps/quickjs/libbf.c \ deps/quickjs/libbf.c \
@ -759,7 +588,7 @@ $(MINIUNZIP_OBJS): CFLAGS += \
LDFLAGS += \ LDFLAGS += \
-pthread \ -pthread \
-lm -lm
$(LINUX_TARGETS) $(MACOS_TARGETS) $(IOS_TARGETS) $(IOSSIM_TARGETS) $(AARCH64_TARGETS): LDFLAGS += \ debug release $(MACOS_TARGETS) $(IOS_TARGETS) $(IOSSIM_TARGETS): LDFLAGS += \
-lssl \ -lssl \
-lcrypto -lcrypto
ifneq ($(UNAME_S),Haiku) ifneq ($(UNAME_S),Haiku)
@ -794,33 +623,13 @@ $(IOS_TARGETS) $(IOSSIM_TARGETS): LDFLAGS += \
-framework UIKit \ -framework UIKit \
-framework WebKit -framework WebKit
## unix: debug release
## Common targets: win: windebug winrelease
## all: $(BUILD_TYPES)
debug: ## Build a debug executable for the current platform.
release: ## Build a release executable for the current platform.
armdebug: ## Cross-compile aarch64 debug on Linux.
armrelease: ## Cross-compile aarch64 release on Linux.
all: $(BUILD_TYPES) ## Build all targets that appear possible to build on this machine.
unix: debug release ## Build all UNIX targets.
win: windebug winrelease ## Build all Windows targets.
.PHONY: all win unix .PHONY: all win unix
##
## Windows targets:
##
windebug: ## Build a debug win32 executable.
winrelease: ## Build a release win32 executable.
##
## MacOS targets:
##
macosdebug: ## Build a MacOS debug executable.
macosrelease: ## Build a MacOS release executable.
ALL_APP_OBJS := \ ALL_APP_OBJS := \
$(APP_OBJS) \ $(APP_OBJS) \
$(ARES_OBJS) \
$(BLOWFISH_OBJS) \ $(BLOWFISH_OBJS) \
$(LIBBACKTRACE_OBJS) \ $(LIBBACKTRACE_OBJS) \
$(MINIUNZIP_OBJS) \ $(MINIUNZIP_OBJS) \
@ -828,7 +637,8 @@ ALL_APP_OBJS := \
$(QUICKJS_OBJS) \ $(QUICKJS_OBJS) \
$(SODIUM_OBJS) \ $(SODIUM_OBJS) \
$(SQLITE_OBJS) \ $(SQLITE_OBJS) \
$(UV_OBJS) $(UV_OBJS) \
$(XOPT_OBJS)
DEPS = $(ALL_APP_OBJS:.o=.d) DEPS = $(ALL_APP_OBJS:.o=.d)
-include $(DEPS) -include $(DEPS)
@ -873,18 +683,7 @@ src/android/AndroidManifest.xml : $(firstword $(MAKEFILE_LIST))
-e 's/android:targetSdkVersion="[[:digit:]]*"/android:targetSdkVersion="$(ANDROID_TARGET_SDK_VERSION)"/' \ -e 's/android:targetSdkVersion="[[:digit:]]*"/android:targetSdkVersion="$(ANDROID_TARGET_SDK_VERSION)"/' \
$@ $@
## # Android support.
## Android targets:
##
androiddebug: ## Build a debug 64-bit ARM Android APK.
androidrelease: ## Build a release 64-bit ARM Android APK.
androiddebug-armv7a: ## Build a debug 32-bit ARM Android APK.
androidrelease-armv7a: ## Build a release 32-bit ARM Android APK.
androiddebug-x86: ## Build a debug x86 Android APK.
androidrelease-x86: ## Build a release x86 Android APK.
androiddebug-x86_64: ## Build a debug x86_64 Android APK.
androidrelease-x86_64: ## Build a release x86_64 Android APK.
out/res/layout_activity_main.xml.flat: src/android/res/layout/activity_main.xml out/res/layout_activity_main.xml.flat: src/android/res/layout/activity_main.xml
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
@echo "[aapt2] $@" @echo "[aapt2] $@"
@ -896,37 +695,20 @@ out/res/drawable_icon.xml.flat: src/android/res/drawable/icon.xml
@$(ANDROID_BUILD_TOOLS)/aapt2 compile -o out/res/ src/android/res/drawable/icon.xml @$(ANDROID_BUILD_TOOLS)/aapt2 compile -o out/res/ src/android/res/drawable/icon.xml
out/apk/res.apk out/gen/com/unprompted/tildefriends/R.java: out/res/layout_activity_main.xml.flat out/res/drawable_icon.xml.flat src/android/AndroidManifest.xml out/apk/res.apk out/gen/com/unprompted/tildefriends/R.java: out/res/layout_activity_main.xml.flat out/res/drawable_icon.xml.flat src/android/AndroidManifest.xml
@echo [aapt2 link] res.apk @mkdir -p $(dir $@)
@mkdir -p out/apk/ @$(ANDROID_BUILD_TOOLS)/aapt2 link -I $(ANDROID_PLATFORM)/android.jar out/res/layout_activity_main.xml.flat out/res/drawable_icon.xml.flat --manifest src/android/AndroidManifest.xml -o out/apk/res.apk --java out/gen/
@$(ANDROID_BUILD_TOOLS)/aapt2 link -I $(ANDROID_PLATFORM)/android.jar out/res/layout_activity_main.xml.flat out/res/drawable_icon.xml.flat \
--min-sdk-version $(ANDROID_MIN_SDK_VERSION) \
--target-sdk-version $(ANDROID_TARGET_SDK_VERSION) \
--manifest src/android/AndroidManifest.xml \
-o out/apk/res.apk \
--java out/gen/
out/apk/res.fdroid.apk out/gen_fdroid/com/unprompted/tildefriends/R.java: out/res/layout_activity_main.xml.flat out/res/drawable_icon.xml.flat src/android/AndroidManifest.xml
@echo [aapt2 link] res.fdroid.apk
@mkdir -p out/apk/
@$(ANDROID_BUILD_TOOLS)/aapt2 link -I $(ANDROID_PLATFORM)/android.jar out/res/layout_activity_main.xml.flat out/res/drawable_icon.xml.flat \
--min-sdk-version $(ANDROID_MIN_SDK_VERSION) \
--target-sdk-version $(ANDROID_TARGET_SDK_VERSION) \
--rename-manifest-package com.unprompted.tildefriends.fdroid \
--manifest src/android/AndroidManifest.xml \
-o out/apk/res.fdroid.apk \
--java out/gen_fdroid/
JAVA_FILES := out/gen/com/unprompted/tildefriends/R.java $(wildcard src/android/com/unprompted/tildefriends/*.java) JAVA_FILES := out/gen/com/unprompted/tildefriends/R.java $(wildcard src/android/com/unprompted/tildefriends/*.java)
CLASS_FILES := $(foreach src,$(JAVA_FILES),out/classes/com/unprompted/tildefriends/$(notdir $(src:.java=.class))) CLASS_FILES := $(foreach src,$(JAVA_FILES),out/classes/com/unprompted/tildefriends/$(notdir $(src:.java=.class)))
$(CLASS_FILES) &: $(JAVA_FILES) $(CLASS_FILES) &: $(JAVA_FILES)
@echo "[javac] $(CLASS_FILES)" @echo "[javac] $(CLASS_FILES)"
@javac --release 8 -encoding UTF-8 -Xlint:deprecation -XDuseUnsharedTable=true -classpath $(ANDROID_PLATFORM)/android.jar:$(ANDROID_BUILD_TOOLS)/core-lambda-stubs.jar -d out/classes $(JAVA_FILES) @javac --release 8 -Xlint:deprecation -classpath $(ANDROID_PLATFORM)/android.jar -d out/classes $(JAVA_FILES)
out/apk/classes.dex: $(CLASS_FILES) out/apk/classes.dex: $(CLASS_FILES)
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
@echo "[d8] $@" @echo "[d8] $@"
@$(ANDROID_BUILD_TOOLS)/d8 --lib $(ANDROID_PLATFORM)/android.jar --output $(dir $@) out/classes/com/unprompted/tildefriends/*.class @$(ANDROID_BUILD_TOOLS)/d8 --$(BUILD_TYPE) --lib $(ANDROID_PLATFORM)/android.jar --output $(dir $@) out/classes/com/unprompted/tildefriends/*.class
PACKAGE_DIRS := \ PACKAGE_DIRS := \
apps/ \ apps/ \
@ -935,155 +717,55 @@ PACKAGE_DIRS := \
deps/prettier/ \ deps/prettier/ \
deps/lit/ deps/lit/
RAW_FILES := $(sort $(filter-out apps/blog% apps/issues% apps/welcome% apps/journal% %.map, $(shell find $(PACKAGE_DIRS) -type f -not -name '.*'))) RAW_FILES := $(filter-out apps/blog% apps/gg% apps/issues% apps/welcome% apps/journal% %.map, $(shell find $(PACKAGE_DIRS) -type f))
out/apk/TildeFriends-arm-debug.unsigned.apk: BUILD_TYPE := debug out/apk/TildeFriends-arm-debug.unsigned.apk: BUILD_TYPE := debug
out/apk/TildeFriends-arm-release.unsigned.apk: BUILD_TYPE := release out/apk/TildeFriends-arm-release.unsigned.apk: BUILD_TYPE := release
out/apk/TildeFriends-x86-debug.unsigned.apk: BUILD_TYPE := debug out/apk/TildeFriends-x86-debug.unsigned.apk: BUILD_TYPE := debug
out/apk/TildeFriends-x86-release.unsigned.apk: BUILD_TYPE := release out/apk/TildeFriends-x86-release.unsigned.apk: BUILD_TYPE := release
out/apk/TildeFriends-release.fdroid.unsigned.apk: BUILD_TYPE := release
out/apk/TildeFriends-arm-debug.unsigned.apk: out/apk/classes.dex out/androiddebug/tildefriends out/androiddebug-armv7a/tildefriends $(RAW_FILES) out/apk/res.apk out/apk/TildeFriends-arm-debug.unsigned.apk: out/apk/classes.dex out/androiddebug/tildefriends out/androiddebug-armv7a/tildefriends $(RAW_FILES) out/apk/res.apk
out/apk/TildeFriends-arm-release.unsigned.apk: out/apk/classes.dex out/androidrelease/tildefriends out/androidrelease-armv7a/tildefriends $(RAW_FILES) out/apk/res.apk out/apk/TildeFriends-arm-release.unsigned.apk: out/apk/classes.dex out/androidrelease/tildefriends out/androidrelease-armv7a/tildefriends $(RAW_FILES) out/apk/res.apk
out/apk/TildeFriends-x86-debug.unsigned.apk: out/apk/classes.dex out/androiddebug-x86_64/tildefriends out/androiddebug-x86/tildefriends $(RAW_FILES) out/apk/res.apk out/apk/TildeFriends-x86-debug.unsigned.apk: out/apk/classes.dex out/androiddebug-x86_64/tildefriends out/androiddebug-x86/tildefriends $(RAW_FILES) out/apk/res.apk
out/apk/TildeFriends-x86-release.unsigned.apk: out/apk/classes.dex out/androidrelease-x86_64/tildefriends out/androidrelease-x86/tildefriends $(RAW_FILES) out/apk/res.apk out/apk/TildeFriends-x86-release.unsigned.apk: out/apk/classes.dex out/androidrelease-x86_64/tildefriends out/androidrelease-x86/tildefriends $(RAW_FILES) out/apk/res.apk
out/apk/TildeFriends-release.fdroid.unsigned.apk: out/apk/classes.dex out/androidrelease/tildefriends out/androidrelease-armv7a/tildefriends out/androidrelease-x86_64/tildefriends out/androidrelease-x86/tildefriends $(RAW_FILES) out/apk/res.fdroid.apk
$(BUNDLETOOL):
@echo [curl] $(BUNDLETOOL_URL) TO $@
@curl -q -L --create-dirs -o $@ $(BUNDLETOOL_URL)
out/TildeFriends.aab: out/apk/classes.dex $(filter-out %debug%, $(ANDROID_TARGETS)) $(RAW_FILES) out/apk/res.apk src/android/AndroidManifest.xml $(BUNDLETOOL)
@rm -rf out/aab/staging/
@mkdir -p out/aab/staging
@$(ANDROID_BUILD_TOOLS)/aapt2 link --proto-format -o out/aab/temporary.apk \
-I $(ANDROID_PLATFORM)/android.jar \
--min-sdk-version $(ANDROID_MIN_SDK_VERSION) \
--target-sdk-version $(ANDROID_TARGET_SDK_VERSION) \
--manifest src/android/AndroidManifest.xml \
-R out/res/layout_activity_main.xml.flat \
-R out/res/drawable_icon.xml.flat \
--auto-add-overlay
@unzip out/aab/temporary.apk -d out/aab/staging/
@mkdir -p out/aab/staging/root/deps
@mkdir -p out/aab/staging/classes
@mkdir -p out/aab/staging/dex
@mkdir -p out/aab/staging/manifest
@mv out/aab/staging/AndroidManifest.xml out/aab/staging/manifest/AndroidManifest.xml
@cp out/apk/classes.dex out/aab/staging/dex/
@rm -fv out/base.zip
@mkdir -p out/aab/staging/lib/arm64-v8a out/aab/staging/lib/armeabi-v7a out/aab/staging/lib/x86_64 out/aab/staging/lib/x86
@cp out/androidrelease/tildefriends out/aab/staging/lib/arm64-v8a/libtildefriends.so
@cp out/androidrelease-armv7a/tildefriends out/aab/staging/lib/armeabi-v7a/libtildefriends.so
@cp out/androidrelease-x86_64/tildefriends out/aab/staging/lib/x86_64/libtildefriends.so
@cp out/androidrelease-x86/tildefriends out/aab/staging/lib/x86/libtildefriends.so
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/aab/staging/lib/arm64-v8a/libtildefriends.so
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/aab/staging/lib/armeabi-v7a/libtildefriends.so
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/aab/staging/lib/x86_64/libtildefriends.so
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/aab/staging/lib/x86/libtildefriends.so
@cp -r apps/ out/aab/staging/root/
@rm -rf out/aab/staging/root/apps/welcome*
@cp -r core/ out/aab/staging/root/
@cp -r deps/prettier/ out/aab/staging/root/deps/
@cp -r deps/lit/ out/aab/staging/root/deps/
@cp -r deps/codemirror/ out/aab/staging/root/deps/
@cd out/aab/staging/; zip -r ../base.zip *; cd ../../../
@java -jar $(BUNDLETOOL) build-bundle --overwrite --config=src/android/BundleConfig.json --modules=out/aab/base.zip --output=$@
@jarsigner -keystore .keys/android.jks $@ androidKey -storepass android
aab: out/TildeFriends.aab ## Build an Android App Bundle.
.PHONY: aab
out/TildeFriends.apks: out/TildeFriends.aab $(BUNDLETOOL)
@java -jar $(BUNDLETOOL) build-apks --bundle out/TildeFriends.aab --overwrite --output $@ --ks .keys/android.jks --ks-key-alias androidKey --ks-pass pass:android
aabgo: out/TildeFriends.apks $(BUNDLETOOL)
@java -jar $(BUNDLETOOL) install-apks --apks out/TildeFriends.apks
@adb shell am start com.unprompted.tildefriends/.TildeFriendsActivity
out/apk/TildeFriends-arm-%.unsigned.apk: out/apk/TildeFriends-arm-%.unsigned.apk:
@mkdir -p $(dir $@) out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/ out/apk-arm-$(BUILD_TYPE)/lib/armeabi-v7a/ @mkdir -p $(dir $@) out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/ out/apk-arm-$(BUILD_TYPE)/lib/armeabi-v7a/
@echo "[aapt] $@" @echo "[aapt] $@"
@cp out/android$(BUILD_TYPE)/tildefriends out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/libtildefriends.so @cp out/android$(BUILD_TYPE)/tildefriends out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/tildefriends.so
@cp out/android$(BUILD_TYPE)-armv7a/tildefriends out/apk-arm-$(BUILD_TYPE)/lib/armeabi-v7a/libtildefriends.so @cp out/android$(BUILD_TYPE)-armv7a/tildefriends out/apk-arm-$(BUILD_TYPE)/lib/armeabi-v7a/tildefriends.so
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-arm-$(BUILD_TYPE)/lib/arm64-v8a/libtildefriends.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/libtildefriends.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 $@.zip @cp out/apk/res.apk $@
@cp out/apk/classes.dex out/apk-arm-$(BUILD_TYPE)/ @cp out/apk/classes.dex out/apk-arm-$(BUILD_TYPE)/
@cd out/apk-arm-$(BUILD_TYPE) && zip -u ../../$@.zip -q -9 -r . && cd ../../ @cd out/apk-arm-$(BUILD_TYPE) && zip -u ../../$@ -q -9 -r . && cd ../../
@zip -u $@.zip -q -9 $(RAW_FILES) @zip -u $@ -q -9 $(RAW_FILES)
@$(ANDROID_BUILD_TOOLS)/zipalign -f 4 $@.zip $@
out/apk/TildeFriends-x86-%.unsigned.apk: out/apk/TildeFriends-x86-%.unsigned.apk:
@mkdir -p $(dir $@) out/apk-x86-$(BUILD_TYPE)/lib/x86_64/ out/apk-x86-$(BUILD_TYPE)/lib/x86/ @mkdir -p $(dir $@) out/apk-x86-$(BUILD_TYPE)/lib/x86_64/ out/apk-x86-$(BUILD_TYPE)/lib/x86/
@echo "[aapt] $@" @echo "[aapt] $@"
@cp out/android$(BUILD_TYPE)-x86_64/tildefriends out/apk-x86-$(BUILD_TYPE)/lib/x86_64/libtildefriends.so @cp out/android$(BUILD_TYPE)-x86_64/tildefriends out/apk-x86-$(BUILD_TYPE)/lib/x86_64/tildefriends.so
@cp out/android$(BUILD_TYPE)-x86/tildefriends out/apk-x86-$(BUILD_TYPE)/lib/x86/libtildefriends.so @cp out/android$(BUILD_TYPE)-x86/tildefriends out/apk-x86-$(BUILD_TYPE)/lib/x86/tildefriends.so
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-x86-$(BUILD_TYPE)/lib/x86_64/libtildefriends.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/libtildefriends.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 $@.zip @cp out/apk/res.apk $@
@cp out/apk/classes.dex out/apk-x86-$(BUILD_TYPE)/ @cp out/apk/classes.dex out/apk-x86-$(BUILD_TYPE)/
@cd out/apk-x86-$(BUILD_TYPE) && zip -u ../../$@.zip -q -9 -r . && cd ../../ @cd out/apk-x86-$(BUILD_TYPE) && zip -u ../../$@ -q -9 -r . && cd ../../
@zip -u $@.zip -q -9 $(RAW_FILES) @zip -u $@ -q -9 $(RAW_FILES)
@$(ANDROID_BUILD_TOOLS)/zipalign -f 4 $@.zip $@
out/apk/TildeFriends-%.fdroid.unsigned.apk:
@rm -rf out/apk-fdroid-$(BUILD_TYPE) out/apk-fdroid-$(BUILD_TYPE)-raw
@mkdir -p $(dir $@) out/apk-fdroid-$(BUILD_TYPE)/lib/x86_64/ out/apk-fdroid-$(BUILD_TYPE)/lib/x86/ out/apk-fdroid-$(BUILD_TYPE)/lib/arm64-v8a/ out/apk-fdroid-$(BUILD_TYPE)/lib/armeabi-v7a/
@echo "[aapt] $@"
@cp out/android$(BUILD_TYPE)-x86_64/tildefriends out/apk-fdroid-$(BUILD_TYPE)/lib/x86_64/libtildefriends.so
@cp out/android$(BUILD_TYPE)-x86/tildefriends out/apk-fdroid-$(BUILD_TYPE)/lib/x86/libtildefriends.so
@cp out/android$(BUILD_TYPE)/tildefriends out/apk-fdroid-$(BUILD_TYPE)/lib/arm64-v8a/libtildefriends.so
@cp out/android$(BUILD_TYPE)-armv7a/tildefriends out/apk-fdroid-$(BUILD_TYPE)/lib/armeabi-v7a/libtildefriends.so
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-fdroid-$(BUILD_TYPE)/lib/x86_64/libtildefriends.so
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-fdroid-$(BUILD_TYPE)/lib/x86/libtildefriends.so
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-fdroid-$(BUILD_TYPE)/lib/arm64-v8a/libtildefriends.so
@$(ANDROID_NDK)/toolchains/llvm/prebuilt/linux-x86_64/bin/llvm-strip out/apk-fdroid-$(BUILD_TYPE)/lib/armeabi-v7a/libtildefriends.so
@cp out/apk/res.fdroid.apk $@.zip
@cp out/apk/classes.dex out/apk-fdroid-$(BUILD_TYPE)/classes.dex
@touch -d @0 out/apk-fdroid-$(BUILD_TYPE)/classes.dex out/apk-fdroid-$(BUILD_TYPE)/lib/*/libtildefriends.so
@chmod 755 out/apk-fdroid-$(BUILD_TYPE)/classes.dex out/apk-fdroid-$(BUILD_TYPE)/lib/*/libtildefriends.so
@cd out/apk-fdroid-$(BUILD_TYPE) && zip -X -u ../../$@.zip -q classes.dex lib/*/libtildefriends.so && cd ../../
@mkdir out/apk-fdroid-$(BUILD_TYPE)-raw
@for i in $(RAW_FILES); do mkdir -p $$(dirname out/apk-fdroid-$(BUILD_TYPE)-raw/$$i) && cp $$i out/apk-fdroid-$(BUILD_TYPE)-raw/$$i && touch -d @0 out/apk-fdroid-$(BUILD_TYPE)-raw/$$i && chmod 644 out/apk-fdroid-$(BUILD_TYPE)-raw/$$i; done
@cd out/apk-fdroid-$(BUILD_TYPE)-raw && zip -X -u ../../$@.zip -q $(RAW_FILES) && cd ../../
@$(ANDROID_BUILD_TOOLS)/zipalign -f 4 $@.zip $@
out/%.apk: out/apk/%.unsigned.apk out/%.apk: out/apk/%.unsigned.apk
@echo "[apksigner] $(notdir $@)" @echo "[apksigner] $(notdir $@)"
@$(ANDROID_BUILD_TOOLS)/apksigner sign --ks .keys/android.jks --ks-key-alias androidKey --ks-pass pass:android --key-pass pass:android --min-sdk-version $(ANDROID_MIN_SDK_VERSION) --out $@ $< @$(ANDROID_BUILD_TOOLS)/apksigner sign --ks .keys/android.jks --ks-key-alias androidKey --ks-pass pass:android --key-pass pass:android --out $@ $<
out/%.zopfli.apk: out/%.apk release-apk: out/TildeFriends-arm-release.apk out/TildeFriends-x86-release.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 ## Build an Android release APK.
.PHONY: release-apk .PHONY: release-apk
fdroid: out/apk/TildeFriends-release.fdroid.unsigned.apk ## Build Android APK for distribution on F-Droid. releaseapkgo: out/TildeFriends-arm-release.apk
.PHONY: fdroid
apkgo: out/TildeFriends-arm-debug.apk ## Build, install, and run a debug Android APK.
@adb install -r $< @adb install -r $<
@adb shell am start com.unprompted.tildefriends/.TildeFriendsActivity @adb shell am start com.unprompted.tildefriends/.MainActivity
.PHONY: apkgo
releaseapkgo: out/TildeFriends-arm-release.apk ## Build, install, and run a release Android APK.
@adb install -r $<
@adb shell am start com.unprompted.tildefriends/.TildeFriendsActivity
.PHONY: releaseapkgo .PHONY: releaseapkgo
apklog: ## Display Android log output. # iOS Support
@adb logcat *:S tildefriends
.PHONY: apklog
##
## iPhoneOS targets:
##
iosdebug: ## Build a debug iPhoneOS executable.
iosrelease: ## Build a release iPhoneOS executable.
out/%.app/Info.plist: src/ios/Info.plist out/%.app/Info.plist: src/ios/Info.plist
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
@cp -v $< $@ @cp -v $< $@
@ -1091,14 +773,12 @@ out/%.app/tildefriends.png: src/ios/tildefriends.png
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
@cp -v $< $@ @cp -v $< $@
out/data.zip: $(RAW_FILES) out/%/data.zip: $(RAW_FILES)
@echo [zip] $@
@zip -u $@ -q -9 $(RAW_FILES) @zip -u $@ -q -9 $(RAW_FILES)
out/tildefriends-%.app/tildefriends: out/%/tildefriends out/tildefriends-%.app/Info.plist out/tildefriends-%.app/tildefriends.png out/data.zip out/tildefriends-%.app/tildefriends: out/%/tildefriends out/tildefriends-%.app/Info.plist out/tildefriends-%.app/tildefriends.png out/tildefriends-%.app/data.zip
@mkdir -p $(dir $@) @mkdir -p $(dir $@)
@cp -v $< $@ @cp -v $< $@
@cp -v out/data.zip $(@D)/
ifeq ($(HAVE_LINUX_IOS),1) ifeq ($(HAVE_LINUX_IOS),1)
@zsign -q -k .keys/apple.p12 -f -m src/ios/embedded.mobileprovision $(realpath $(dir $@)) @zsign -q -k .keys/apple.p12 -f -m src/ios/embedded.mobileprovision $(realpath $(dir $@))
endif endif
@ -1111,146 +791,53 @@ out/tildefriends-%.ipa: out/tildefriends-ios%.app/tildefriends
@cd $@.tmp/ && zip -u ../../$@ -q -9 -r ./ @cd $@.tmp/ && zip -u ../../$@ -q -9 -r ./
@rm -rf $@.tmp/ @rm -rf $@.tmp/
iossimdebug-app: out/tildefriends-iossimdebug.app/tildefriends
iossimrelease-app: out/tildefriends-iossimrelease.app/tildefriends
iosdebug-app: out/tildefriends-iosdebug.app/tildefriends
iosrelease-app: out/tildefriends-iosrelease.app/tildefriends
out/%/tildefriends.standalone: out/%/tildefriends out/data.zip iosdebug-ipa: out/tildefriends-debug.ipa
@echo "[standalone] $@" iosrelease-ipa: out/tildefriends-release.ipa
@cat $< out/data.zip > $@
@chmod +x $@
out/%/tildefriends.standalone.exe: out/%/tildefriends.exe out/data.zip
@echo "[standalone] $@"
@cat $< out/data.zip > $@
@chmod +x $@
iossimdebug-app: out/tildefriends-iossimdebug.app/tildefriends ## Build a debug iOS Simulator .app directory.
iossimrelease-app: out/tildefriends-iossimrelease.app/tildefriends ## Build a release iOS Simulator .app directory.
iosdebug-app: out/tildefriends-iosdebug.app/tildefriends ## Build a debug iOS .app directory.
iosrelease-app: out/tildefriends-iosrelease.app/tildefriends ## Build a release iOS .app directory.
iosdebug-ipa: out/tildefriends-debug.ipa ## Build a debug iOS .ipa.
iosrelease-ipa: out/tildefriends-release.ipa ## Build a release iOS .ipa.
.PHONY: iossimdebug-app iossimrelease-app iosdebug-app iosrelease-app .PHONY: iossimdebug-app iossimrelease-app iosdebug-app iosrelease-app
ios%go: out/tildefriends-ios%.app/tildefriends ios%go: out/tildefriends-ios%.app/tildefriends
ideviceinstaller -i $(realpath $(dir $<)) ideviceinstaller -i $(realpath $(dir $<))
iossimdebuggo: out/tildefriends-iossimdebug.app/tildefriends ## Build, install, and run an iOS debug build. iossimdebuggo: out/tildefriends-iossimdebug.app/tildefriends
xcrun simctl install booted out/tildefriends-iossimdebug.app/ xcrun simctl install booted out/tildefriends-iossimdebug.app/
xcrun simctl launch booted com.unprompted.tildefriends xcrun simctl launch booted com.unprompted.tildefriends
.PHONY: iossimdebuggo .PHONY: iossimdebuggo
ANDROID_DEPS := deps/openssl/android/arm64-v8a/usr/local/lib/libssl.a apklog:
$(ANDROID_DEPS): @adb logcat *:S tildefriends
+@ANDROID_NDK_ROOT=$(ANDROID_NDK) tools/ssl-android .PHONY: apklog
$(filter $(BUILD_DIR)/android%,$(APP_OBJS)): | $(ANDROID_DEPS)
ifeq ($(UNAME_S),Linux) fetchdeps:
LOCAL_DEPS := deps/openssl/$(UNAME_S)/$(UNAME_M)/usr/local/lib/libssl.a @echo "[fetch] libuv"
$(LOCAL_DEPS): @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)
+@OPTIONS=-flto tools/ssl-local @test -d deps/libuv/ || (mkdir -p deps/libuv/ && tar -C deps/libuv/ -m --strip=1 -xf out/deps/libuv.tar.gz)
$(filter $(BUILD_DIR)/debug/%,$(APP_OBJS)) $(filter $(BUILD_DIR)/release/%,$(APP_OBJS)): | $(LOCAL_DEPS)
ifeq ($(HAVE_CROSS_AARCH64),1)
LOCAL_DEPS := deps/openssl/$(UNAME_S)/aarch64/usr/local/lib/libssl.a
$(LOCAL_DEPS):
+@OPTIONS="--cross-compile-prefix=aarch64-linux-gnu- -flto" BUILD_TARGET=aarch64 tools/ssl-local
$(filter $(BUILD_DIR)/armdebug/%,$(APP_OBJS)) $(filter $(BUILD_DIR)/armrelease/%,$(APP_OBJS)): | $(LOCAL_DEPS)
endif
ifeq ($(HAVE_LINUX_IOS),1)
LOCAL_DEPS := deps/openssl/$(UNAME_S)/ios64-cross/usr/local/lib/libssl.a
$(LOCAL_DEPS):
+@PATH=deps/ios_toolchain/target/bin:$$PATH \
BUILD_TARGET=ios64-cross \
SSL_TARGET=ios64-cross \
CROSS_COMPILE=../../deps/ios_toolchain/target/bin/arm-apple-darwin11- \
CROSS_TOP=../../deps/ios_toolchain/target \
CROSS_SDK=iPhoneOS18.2.sdk \
CC=clang \
OPTIONS=-miphoneos-version-min=9.0 \
tools/ssl-local
$(filter $(BUILD_DIR)/ios%,$(APP_OBJS)): | $(LOCAL_DEPS)
endif
endif
ifeq ($(UNAME_S),Darwin)
LOCAL_DEPS := deps/openssl/$(UNAME_S)/$(UNAME_M)/usr/local/lib/libssl.a
$(LOCAL_DEPS):
+@OPTIONS=-flto tools/ssl-local
$(filter $(BUILD_DIR)/macosdebug/%,$(APP_OBJS)) $(filter $(BUILD_DIR)/macosrelease/%,$(APP_OBJS)): | $(LOCAL_DEPS)
endif
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
##
## Linux package targets:
##
out/tildefriends-x86_64.AppImage: out/release/tildefriends out/data.zip
@echo "[appimage] $$@"
@rm -rf out/tildefriends.AppDir
@mkdir -p out/tildefriends.AppDir/usr/bin
@mkdir -p out/tildefriends.AppDir/usr/share/applications
@mkdir -p out/tildefriends.AppDir/usr/share/icons/hicolor/scalable/apps
@mkdir -p out/tildefriends.AppDir/usr/share/tildefriends
@echo $(APPIMAGETOOL_MD5) > out/appimagetool.md5
@test -x out/appimagetool || curl -q -L -o out/appimagetool $(APPIMAGETOOL_URL) && md5sum -c out/appimagetool.md5 && chmod +x out/appimagetool
@echo "[Desktop Entry]\nName=tildefriends\nExec=/usr/bin/tildefriends\nIcon=/usr/share/icons/hicolor/scalable/apps/tildefriends\nType=Application\nCategories=Network" > out/tildefriends.AppDir/tildefriends.desktop
@cp src/ios/tildefriends.svg out/tildefriends.AppDir/usr/share/icons/hicolor/scalable/apps/
@cp src/ios/tildefriends.svg out/tildefriends.AppDir/
@cp out/release/tildefriends out/tildefriends.AppDir/usr/bin/
@cp out/data.zip out/tildefriends.AppDir/usr/share/tildefriends/data.zip
@echo "#!/bin/sh\n\$${APPDIR}/usr/bin/tildefriends run -z \$$APPDIR/usr/share/tildefriends/data.zip" > out/tildefriends.AppDir/AppRun
@chmod +x out/tildefriends.AppDir/AppRun
@cd out; ./appimagetool --appimage-extract; cd ..
@cd out; unset SOURCE_DATE_EPOCH; PATH=$$PATH:squashfs-root/usr/bin ARCH=x86_64 squashfs-root/usr/bin/appimagetool -u 'zsync|https://dev.tildefriends.net/releases/tildefriends-x86_64.AppImage.zsync' tildefriends.AppDir tildefriends-x86_64.AppImage; cd ..
appimage: out/tildefriends-x86_64.AppImage ## Build an AppImage.
.PHONY: appimage
flatpak: out/ ## Build a flatpak.
flatpak-builder --force-clean --user --install-deps-from=flathub --install --repo=out/flatpak-repo out/flatpak src/com.unprompted.tildefriends.yml
flatpak build-bundle out/flatpak-repo out/tildefriends.flatpak com.unprompted.tildefriends
.PHONY: flatpak
##
## Targets for release management:
##
fetchdeps: ## Update various external sources that live in the tree that can't be pulled in as git submodules.
@echo "[fetch] sqlite" @echo "[fetch] sqlite"
@test -f out/deps/sqlite.zip && test "$$(cat out/deps/sqlite.txt 2>/dev/null)" = $(SQLITE_URL) || (mkdir -p out/deps/ && curl -q $(SQLITE_URL) -o out/deps/sqlite.zip) @test -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/ && test "$$(cat out/deps/sqlite.txt 2>/dev/null)" = $(SQLITE_URL) || (mkdir -p deps/sqlite/ && unzip -qDjo -d deps/sqlite/ out/deps/sqlite.zip) @test -d deps/sqlite/ || (mkdir -p deps/sqlite/ && unzip -qDj -d deps/sqlite/ out/deps/sqlite.zip)
@echo -n $(SQLITE_URL) > out/deps/sqlite.txt
@echo "[fetch] prettier" @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/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/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/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 @test -f deps/prettier/estree.mjs || curl -q --create-dirs -O --output-dir deps/prettier/ https://cdn.jsdelivr.net/npm/prettier@3.2.5/plugins/estree.mjs
.PHONY: fetchdeps .PHONE: fetchdeps
shots: ## Copy generated screenshots from `tildefriends test -t=auto` into place in the metadata/ directory. clean:
@echo [shots] $(wildcard out/screenshot*.png) rm -rf $(BUILD_DIR)
@cp -f out/screenshot*.png metadata/en-US/images/phoneScreenshots/ .PHONY: clean
.PHONY: shots
tarball: ## Build an all-inclusive source tarball (.tar.xz). dist: release-apk iosrelease-ipa
@echo [archive] out/tildefriends-$(VERSION_NUMBER).tar.xz @echo "[export] $$(svn info --show-item url)"
@rm -rf out/tildefriends-$(VERSION_NUMBER) @rm -rf tildefriends-$(VERSION_NUMBER)
@mkdir -p out/tildefriends-$(VERSION_NUMBER) @svn export -q . tildefriends-$(VERSION_NUMBER)
@git ls-files --recurse-submodules | tar -c -T- | tar -x -C out/tildefriends-$(VERSION_NUMBER) @echo "tildefriends-$(VERSION_NUMBER): $(VERSION_NAME)" > tildefriends-$(VERSION_NUMBER)/VERSION
@echo "[tar] tildefriends-$(VERSION_NUMBER).tar.xz"
@tar \ @tar \
--exclude=apps/gg* \
--exclude=apps/welcome* \ --exclude=apps/welcome* \
--exclude=deps/libbacktrace/Isaac.Newton-Opticks.txt \ --exclude=deps/libbacktrace/Isaac.Newton-Opticks.txt \
--exclude=deps/libsodium/builds/msvc/vs* \ --exclude=deps/libsodium/builds/msvc/vs* \
@ -1265,83 +852,23 @@ tarball: ## Build an all-inclusive source tarball (.tar.xz).
--exclude=deps/sqlite/shell.c \ --exclude=deps/sqlite/shell.c \
--exclude=deps/zlib/contrib/vstudio \ --exclude=deps/zlib/contrib/vstudio \
--exclude=deps/zlib/doc \ --exclude=deps/zlib/doc \
-caf out/tildefriends-$(VERSION_NUMBER).tar.xz \ -caf tildefriends-$(VERSION_NUMBER).tar.xz tildefriends-$(VERSION_NUMBER)
-C out/ \ @rm -rf tildefriends-$(VERSION_NUMBER)
tildefriends-$(VERSION_NUMBER)
.PHONY: tarball
dist: ## Build versions of all distributables for release.
dist: release-apk iosrelease-ipa aab $(if $(HAVE_WIN), out/winrelease/tildefriends.standalone.exe) out/TildeFriends-release.fdroid.apk appimage tarball out/release/tildefriends.standalone $(if $(HAVE_CROSS_AARCH64), out/armrelease/tildefriends.standalone)
@mkdir -p dist/
@echo "[cp] tildefriends-$(VERSION_NUMBER).tar.xz"
@cp out/tildefriends-$(VERSION_NUMBER).tar.xz dist/tildefriends-$(VERSION_NUMBER).tar.xz
@echo "[cp] TildeFriends-x86-$(VERSION_NUMBER).apk" @echo "[cp] TildeFriends-x86-$(VERSION_NUMBER).apk"
@cp out/TildeFriends-x86-release.zopfli.apk dist/TildeFriends-x86-$(VERSION_NUMBER).apk @cp out/TildeFriends-x86-release.apk TildeFriends-x86-$(VERSION_NUMBER).apk
@echo "[cp] TildeFriends-arm-$(VERSION_NUMBER).apk" @echo "[cp] TildeFriends-arm-$(VERSION_NUMBER).apk"
@cp out/TildeFriends-arm-release.zopfli.apk dist/TildeFriends-arm-$(VERSION_NUMBER).apk @cp out/TildeFriends-arm-release.apk TildeFriends-arm-$(VERSION_NUMBER).apk
@echo "[cp] TildeFriends-$(VERSION_NUMBER).ipa" @echo "[cp] TildeFriends-$(VERSION_NUMBER).ipa"
@cp out/tildefriends-release.ipa dist/TildeFriends-$(VERSION_NUMBER).ipa @cp out/tildefriends-release.ipa TildeFriends-$(VERSION_NUMBER).ipa
@test $(HAVE_WIN) && echo "[cp] tildefriends-$(VERSION_NUMBER).exe"
@test $(HAVE_WIN) && cp out/winrelease/tildefriends.standalone.exe dist/tildefriends-$(VERSION_NUMBER).exe
@echo "[cp] TildeFriends-$(VERSION_NUMBER).aab"
@cp out/TildeFriends.aab dist/TildeFriends-$(VERSION_NUMBER).aab
@echo "[cp] TildeFriends-$(VERSION_NUMBER).fdroid.apk"
@cp out/TildeFriends-release.fdroid.apk dist/TildeFriends-$(VERSION_NUMBER).fdroid.apk
@echo "[cp] TildeFriends-x86_64-$(VERSION_NUMBER).AppImage"
@cp out/tildefriends-x86_64.AppImage dist/TildeFriends-x86_64-$(VERSION_NUMBER).AppImage
@echo "[cp] tildefriends-linux-$(UNAME_M)-$(VERSION_NUMBER)"
@cp out/release/tildefriends.standalone dist/tildefriends-linux-$(UNAME_M)-$(VERSION_NUMBER)
@test $(HAVE_CROSS_AARCH64) && echo "[cp] tildefriends-linux-aarch64-$(VERSION_NUMBER)"
@test $(HAVE_CROSS_AARCH64) && cp out/armrelease/tildefriends.standalone dist/tildefriends-linux-aarch64-$(VERSION_NUMBER)
.PHONY: dist .PHONY: dist
dist-test: dist ## Exercise some built distributable files, making sure they work as intended. dist-test: dist
@tar -xf tildefriends-$(VERSION_NUMBER).tar.xz @tar -xf tildefriends-$(VERSION_NUMBER).tar.xz
@$(MAKE) -C tildefriends-$(VERSION_NUMBER)/ debug release @$(MAKE) -C tildefriends-$(VERSION_NUMBER)/ debug release
@docker build tildefriends-$(VERSION_NUMBER)/ @docker build tildefriends-$(VERSION_NUMBER)/
@rm -rf tildefriends-$(VERSION_NUMBER) @rm -rf tildefriends-$(VERSION_NUMBER)
.PHONY: dist-test .PHONY: dist-test
## format:
## Targets for tidying up:
##
format: ## Standardize formatting of C source.
@clang-format -i $(wildcard src/*.c src/*.h src/*.m) @clang-format -i $(wildcard src/*.c src/*.h src/*.m)
.PHONY: format .PHONY: format
prettier: ## Standardize formatting of JavaScript and Markdown source.
@npm run prettier
.PHONY: prettier
clean: ## Clean all generated files from the out/ directory.
rm -rf $(BUILD_DIR)
.PHONY: clean
##
## Documentation:
##
help: ## Display this help message.
@awk \
-F: \
-vG=$$(tput setaf 2) \
-vO=$$(tput setaf 3) \
-vB=$$(tput setaf 4) \
-vM=$$(tput setaf 5) \
-vC=$$(tput setaf 6) \
-vR=$$(tput sgr0) ' \
/^## ==.*==$$/ { sub(/^## ?/, ""); printf "%s%s%s\n", C, $$0, R } \
/^##.*:=.*/ { sub(/^## ?/, ""); sub(/:=/, ":"); printf " %s%-20s%s %s%s%s\n", M, $$1, R, O, $$2, R } \
/^##/ { sub(/^## ?/, ""); print $$0 } \
/^[[:alnum:]-]+:.*##/ { \
sub(/:.*##\s?/, ":"); \
printf " %s%-21s%s %s%s%s\n", G, $$1, R, O, $$2, R \
} \
' < $(filter-out %.d,$(MAKEFILE_LIST))
@echo "" # Blank line.
.PHONY: help
.DEFAULT_GOAL := help
docs: ## Build HTML docs.
@doxygen
.PHONY: docs

View File

@ -1,5 +1,4 @@
# Tilde Friends # Tilde Friends
Tilde Friends is a tool for making and sharing. Tilde Friends is a tool for making and sharing.
A public instance lives at https://www.tildefriends.net/. A public instance lives at https://www.tildefriends.net/.
@ -8,70 +7,37 @@ It is both a peer-to-peer social network client, participating in Secure
Scuttlebutt, as well as a platform for writing and running web applications. Scuttlebutt, as well as a platform for writing and running web applications.
## Goals ## Goals
1. Make it easy and fun to run all sorts of web applications. 1. Make it easy and fun to run all sorts of web applications.
2. Provide security that is easy to understand and protects your data. 2. Provide security that is easy to understand and protects your data.
3. Make creating and sharing web applications accessible to anyone with a 3. Make creating and sharing web applications accessible to anyone with a
browser. browser.
## Getting the Source
Tilde Friends uses git submodules, so either:
```
git clone --recurse-submodules https://dev.tildefriends.net/cory/tildefriends.git
```
or:
```
git clone https://dev.tildefriends.net/cory/tildefriends.git
cd tildefriends
git submodule update --init --recursive
```
The `.tar.xz` source releases are all-inclusive.
## Building ## Building
Builds on Linux (x86_64 and aarch64), MacOS, OpenBSD, and Haiku. Builds for
all of those host platforms plus mingw64, iOS, and android.
Builds on Linux (x86_64 and aarch64), MacOS, OpenBSD, and Haiku. It's possible 1. Requires openssl (`libssl-dev`, in debian-speak). All other dependencies
to build for Android, iOS, and Windows on Linux, if you have the right are kept up to date in the tree.
dependencies in the right places. 2. To build, run `make debug` or `make release`. An executable will be
generated in a subdirectory of `out/`.
### Requirements 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
On Linux only, system OpenSSL libraries (`libssl-dev`, in debian-speak) are iosdebug-ipa iosrelease-ipa release-apk`.
assumed to be available. 4. To build in docker, `docker build .`.
5. `make format` will normalize formatting to the coding standard.
On MacOS, Xcode's command-line tools are expected to be available.
### Build Commands
Run `make` with no arguments to see available build targets and options. `make
debug` is a good place to start.
To build in docker, `docker build .`.
`make format` and `make prettier` will normalize formatting to the coding
standard.
## Running ## Running
By default, running the built `tildefriends` executable will start a web server
By default, running the built `out/debug/tildefriends` executable will start a at <http://localhost:12345/>. `tildefriends -h` lists further options.
web server at <http://localhost:12345/>. It expects to be run with the
repository root as the current working directory. `tildefriends -h` lists
further options.
The first user to create an account and log in will be granted administrative The first user to create an account and log in will be granted administrative
privileges. Further administration can be done at privileges. Further administration can be done at
<http://localhost:12345/~core/admin/>. <http://localhost:12345/~core/admin/>.
## Documentation ## Documentation
Docs are a work in progress: Docs are a work in progress:
<https://dev.tildefriends.net/cory/tildefriends/wiki>. <https://www.tildefriends.net/~cory/wiki/#test-wiki/tf-app-quick-reference>.
## License ## License
All code unless otherwise noted in is provided under the All code unless otherwise noted in is provided under the
[MIT](https://opensource.org/licenses/MIT) license. [MIT](https://opensource.org/licenses/MIT) license.

View File

@ -1,5 +1,4 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "🎛", "emoji": "🎛"
"previous": "&R49FywYF8CXPhoSEydLbSCgvCddeyTiBwGuDU/gqY+M=.sha256"
} }

View File

@ -18,13 +18,9 @@ async function main() {
for (let user of await core.users()) { for (let user of await core.users()) {
data.users[user] = await core.permissionsForUser(user); data.users[user] = await core.permissionsForUser(user);
} }
await app.setDocument( await app.setDocument(utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data)));
utf8Decode(getFile('index.html')).replace('$data', JSON.stringify(data))
);
} catch { } catch {
await app.setDocument( await app.setDocument('<span style="color: #f00">Only an administrator can modify these settings.</span>');
'<span style="color: #f00">Only an administrator can modify these settings.</span>'
);
} }
} }
main(); main();

View File

@ -1,41 +1,10 @@
<!doctype html> <!DOCTYPE html>
<html style="width: 100%"> <html style="width: 100%">
<head> <head>
<script> <script>const g_data = $data;</script>
const g_data = $data;
</script>
<link rel="stylesheet" href="w3.css" />
<!-- prettier-ignore -->
<style>
/* 2018 Valiant Poppy */
.w3-theme-l5 {color:#000 !important; background-color:#fbf3f3 !important}
.w3-theme-l4 {color:#000 !important; background-color:#f3d7d6 !important}
.w3-theme-l3 {color:#000 !important; background-color:#e6afae !important}
.w3-theme-l2 {color:#fff !important; background-color:#da8785 !important}
.w3-theme-l1 {color:#fff !important; background-color:#cd5f5d !important}
.w3-theme-d1 {color:#fff !important; background-color:#a93634 !important}
.w3-theme-d2 {color:#fff !important; background-color:#96302e !important}
.w3-theme-d3 {color:#fff !important; background-color:#832a28 !important}
.w3-theme-d4 {color:#fff !important; background-color:#702423 !important}
.w3-theme-d5 {color:#fff !important; background-color:#5e1e1d !important}
.w3-theme-light {color:#000 !important; background-color:#fbf3f3 !important}
.w3-theme-dark {color:#fff !important; background-color:#5e1e1d !important}
.w3-theme-action {color:#fff !important; background-color:#5e1e1d !important}
.w3-theme {color:#fff !important; background-color:#bd3d3a !important}
.w3-text-theme {color:#bd3d3a !important}
.w3-border-theme {border-color:#bd3d3a !important}
.w3-hover-theme:hover {color:#fff !important; background-color:#bd3d3a !important}
.w3-hover-text-theme:hover {color:#bd3d3a !important}
.w3-hover-border-theme:hover {border-color:#bd3d3a !important}
</style>
</head> </head>
<body class="w3-theme-l4"> <body style="color: #fff; width: 100%">
<header class="w3-row w3-padding w3-header w3-theme-l1"> <h1>Tilde Friends Administration</h1>
<h1>Tilde Friends Administration</h1>
</header>
</body> </body>
<script type="module" src="script.js"></script> <script type="module" src="script.js"></script>
</html> </html>

View File

@ -3,113 +3,85 @@ import * as tfrpc from '/static/tfrpc.js';
function delete_user(user) { function delete_user(user) {
if (confirm(`Are you sure you want to delete the user "${user}"?`)) { if (confirm(`Are you sure you want to delete the user "${user}"?`)) {
tfrpc.rpc tfrpc.rpc.delete_user(user).then(function() {
.delete_user(user) alert(`User "${user}" deleted successfully.`);
.then(function () { }).catch(function(error) {
alert(`User "${user}" deleted successfully.`); alert(`Failed to delete user "${user}": ${JSON.stringify(error, null, 2)}.`);
}) });
.catch(function (error) {
alert(
`Failed to delete user "${user}": ${JSON.stringify(error, null, 2)}.`
);
});
} }
} }
function global_settings_set(key, value) { function global_settings_set(key, value) {
tfrpc.rpc tfrpc.rpc.global_settings_set(key, value).then(function() {
.global_settings_set(key, value) alert(`Set "${key}" to "${value}".`);
.then(function () { }).catch(function(error) {
alert(`Set "${key}" to "${value}".`); alert(`Failed to set "${key}": ${JSON.stringify(error, null, 2)}.`);
}) });
.catch(function (error) {
alert(`Failed to set "${key}": ${JSON.stringify(error, null, 2)}.`);
});
} }
function title_case(name) { window.addEventListener('load', function() {
return name const permission_template = (permission) =>
.split('_') html` <code>${permission}</code>`;
.map((x) => x.charAt(0).toUpperCase() + x.substring(1))
.join(' ');
}
window.addEventListener('load', function () {
const permission_template = (permission) => html` <code>${permission}</code>`;
function input_template(key, description) { function input_template(key, description) {
if (description.type === 'boolean') { if (description.type === 'boolean') {
return html` return html`
<li class="w3-row"> <div style="margin-top: 1em">
<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${title_case(key)}</label> <label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
<div class="w3-quarter w3-padding">${description.description}</div> <div>
<div class="w3-quarter w3-padding w3-center"><input class="w3-check" type="checkbox" ?checked=${description.value} id=${'gs_' + key}></input></div> <input type="checkbox" ?checked=${description.value} id=${'gs_' + key}></input>
<button class="w3-quarter w3-button w3-theme-action" @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.firstChild.checked)}>Set</button> <button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.checked)}>Set</button>
</li> <div>${description.description}</div>
</div>
</div>
`; `;
} else if (description.type === 'textarea') { } else if (description.type === 'textarea') {
return html` return html`
<li class="w3-row"> <div style="margin-top: 1em"">
<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold" <label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
>${title_case(key)}</label <div style="width: 100%; padding: 0; margin: 0">
> <div style="width: 90%; padding: 0 margin: 0">
<div class="w3-rest w3-padding">${description.description}</div> <textarea style="vertical-align: top; width: 100%" rows=20 cols=80 id=${'gs_' + key}>${description.value}</textarea>
<textarea </div>
class="w3-input" <button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.firstElementChild.value)}>Set</button>
style="vertical-align: top; resize: vertical" <div>${description.description}</div>
id=${'gs_' + key} </div>
> </div>
${description.value}</textarea
>
<button
class="w3-button w3-right w3-quarter w3-theme-action"
@click=${(e) =>
global_settings_set(
key,
e.srcElement.previousElementSibling.value
)}
>
Set
</button>
</li>
`; `;
} else { } else {
return html` return html`
<li class="w3-row"> <div style="margin-top: 1em">
<label class="w3-quarter" for=${'gs_' + key} style="font-weight: bold">${title_case(key)}</label> <label for=${'gs_' + key} style="font-weight: bold">${key}: </label>
<div class="w3-quarter w3-padding">${description.description}</div> <div>
<input class="w3-input w3-quarter" type="text" value="${description.value}" id=${'gs_' + key}></input> <input type="text" value="${description.value}" id=${'gs_' + key}></input>
<button class="w3-button w3-quarter w3-theme-action" @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.value)}>Set</button> <button @click=${(e) => global_settings_set(key, e.srcElement.previousElementSibling.value)}>Set</button>
</li> <div>${description.description}</div>
</div>
</div>
`; `;
} }
} }
const user_template = (user, permissions) => html` const user_template = (user, permissions) => html`
<li class="w3-card w3-margin"> <li>
<button <button @click=${(e) => delete_user(user)}>
class="w3-button w3-theme-action"
@click=${(e) => delete_user(user)}
>
Delete Delete
</button> </button>
${user}: ${permissions.map((x) => permission_template(x))} ${user}:
${permissions.map(x => permission_template(x))}
</li> </li>
`; `;
const users_template = (users) => const users_template = (users) =>
html` <header class="w3-container w3-theme-l2"><h2>Users</h2></header> html`<h2>Users</h2>
<ul class="w3-ul"> <ul>
${Object.entries(users).map((u) => user_template(u[0], u[1]))} ${Object.entries(users).map(u => user_template(u[0], u[1]))}
</ul>`; </ul>`;
const page_template = (data) => const page_template = (data) =>
html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%"> html`<div style="padding: 0; margin: 0; width: 100%; max-width: 100%">
<header class="w3-container w3-theme-l2"><h2>Global Settings</h2></header> <h2>Global Settings</h2>
<div class="w3-container"> <div>
<ul class="w3-ul"> ${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])}`)}
</ul>
</div> </div>
${users_template(data.users)} ${users_template(data.users)}
</div> `; </div>
`;
render(page_template(g_data), document.body); render(page_template(g_data), document.body);
}); });

View File

@ -1,235 +0,0 @@
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
audio:not([controls]){display:none;height:0}[hidden],template{display:none}
a{background-color:transparent}a:active,a:hover{outline-width:0}
abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none}
code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible}
button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
button,input{overflow:visible}button,select{text-transform:none}
button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}
button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
[type=checkbox],[type=radio]{padding:0}
[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
[type=search]{-webkit-appearance:textfield;outline-offset:-2px}
[type=search]::-webkit-search-decoration{-webkit-appearance:none}
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
/* End extract */
html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden}
h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit}
.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap}
.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}
.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
.w3-main,#main{transition:margin-left .4s}
.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
.w3-bar .w3-button{white-space:normal}
.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
.w3-responsive{display:block;overflow-x:auto}
.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
@media (max-width:1205px){.w3-auto{max-width:95%}}
@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}
.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
.w3-display-position{position:absolute}
.w3-circle{border-radius:50%}
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
.w3-left{float:left!important}.w3-right{float:right!important}
.w3-button:hover{color:#000!important;background-color:#ccc!important}
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
.w3-hover-none:hover{box-shadow:none!important}
/* Colors */
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}

View File

@ -1,5 +1,5 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "📜", "emoji": "📜",
"previous": "&BEf0nraBdHk/+PWqx6tOSu5rheWVaxaL7orAOz3285M=.sha256" "previous": "&miGORZ8BwjHg2YO0t4bms6SI28XWPYqnqOZ8u9zsbZc=.sha256"
} }

View File

@ -21,7 +21,7 @@ function* treeify(prefix, o) {
function markdown(md) { function markdown(md) {
let parsed = new commonmark.Parser().parse(md ?? '*undocumented*'); let parsed = new commonmark.Parser().parse(md ?? '*undocumented*');
return new commonmark.HtmlRenderer({safe: true}).render(parsed); return new commonmark.HtmlRenderer().render(parsed);
} }
function document(api) { function document(api) {

View File

@ -219,7 +219,7 @@ Parses an HTTP response.
* *Object* An object with **bytes_parsed**, **minor_version**, **status**, **message**, and **headers** fields on successful parse. * *Object* An object with **bytes_parsed**, **minor_version**, **status**, **message**, and **headers** fields on successful parse.
`; `;
docs['sha1Digest()'] = ` docs['sha1Digest()'] =`
Calculates a SHA1 digest. Calculates a SHA1 digest.
Completes synchronously. Completes synchronously.

View File

@ -1,5 +1,5 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "💻", "emoji": "💻",
"previous": "&OFzapemXnBpvXSLlNLy3JQHdTT1wJzmBVjUcpiYf8SQ=.sha256" "previous": "&RdVEsVscZm3aWzcMrEZS8mskO5tUmvaEUihex2MMfZQ=.sha256"
} }

View File

@ -19,18 +19,21 @@ async function fetch_info(apps) {
return result; return result;
} }
/**
*
*
*/
async function fetch_shared_apps() { async function fetch_shared_apps() {
let messages = {}; let messages = {};
await ssb.sqlAsync( await ssb.sqlAsync(`
` SELECT messages.*
SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
FROM messages_fts('"application/tildefriends"') FROM messages_fts('"application/tildefriends"')
JOIN messages ON messages.rowid = messages_fts.rowid JOIN messages ON messages.rowid = messages_fts.rowid
ORDER BY messages.timestamp ORDER BY timestamp
`, `,
[], [],
function (row) { function(row) {
let content = JSON.parse(row.content); let content = JSON.parse(row.content);
for (let mention of content.mentions) { for (let mention of content.mentions) {
if (mention?.type === 'application/tildefriends') { if (mention?.type === 'application/tildefriends') {
@ -41,13 +44,10 @@ async function fetch_shared_apps() {
}; };
} }
} }
} });
);
let result = {}; let result = {};
for (let app of Object.values(messages).sort( for (let app of Object.values(messages).sort((x, y) => y.message.timestamp - x.message.timestamp)) {
(x, y) => y.message.timestamp - x.message.timestamp
)) {
let app_object = JSON.parse(utf8Decode(await ssb.blobGet(app.blob))); let app_object = JSON.parse(utf8Decode(await ssb.blobGet(app.blob)));
if (app_object) { if (app_object) {
app_object.blob_id = app.blob; app_object.blob_id = app.blob;
@ -65,7 +65,17 @@ async function main() {
const stylesheet = ` const stylesheet = `
body { body {
color: whitesmoke; color: whitesmoke;
margin: 8px; font-family: sans-serif;
margin: 16px;
}
.container {
display: grid;
grid-template-columns: repeat(auto-fill, 64px);
gap: 1em;
justify-content: space-around;
background-color: #ffffff10;
border: 2px solid #073642;
border-radius: 8px;
} }
.app { .app {
@ -87,25 +97,16 @@ async function main() {
`; `;
const body = ` const body = `
<h1 style="text-shadow: #808080 0 0 10px;">Welcome to Tilde Friends</h1> <h1 style="text-shadow: #808080 0 0 10px;">Welcome to Tilde Friends.</h1>
<div id="apps" class="w3-card-4 w3-dark-gray w3-margin-top"> <h2>your apps</h2>
<header class="w3-container w3-light-blue"> <div id="apps" class="container"></div>
<h2>Your Apps</h2>
</header>
</div>
<div id="shared_apps" class="w3-card-4 w3-dark-gray w3-margin-top"> <h2>shared apps</h2>
<header class="w3-container w3-light-blue"> <div id="shared_apps" class="container"></div>
<h2>Shared Apps</h2>
</header>
</div>
<div id="core_apps" class="w3-card-4 w3-dark-gray w3-margin-top"> <h2>core apps</h2>
<header class="w3-container w3-light-blue"> <div id="core_apps" class="container"></div>
<h2>Core Apps</h2>
</header>
</div>
`; `;
const script = ` const script = `
@ -121,13 +122,9 @@ async function main() {
// For each app in the provided list // For each app in the provided list
for (let app of Object.keys(apps).sort()) { for (let app of Object.keys(apps).sort()) {
// Create the item // Create the item
let inline = document.createElement('div'); let div = list.appendChild(document.createElement('div'));
inline.style.display = 'inline-block';
inline.classList.add('w3-button');
list.appendChild(inline);
let div = document.createElement('div');
inline.appendChild(div);
div.classList.add('app'); div.classList.add('app');
// The app's icon // The app's icon
@ -160,13 +157,12 @@ async function main() {
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<link type="text/css" rel="stylesheet" href="w3.css"></link>
<style> <style>
${stylesheet} ${stylesheet}
</style> </style>
</head> </head>
<body class="w3-darkgray"> <body>
${body} ${body}
</body> </body>

View File

@ -1,235 +0,0 @@
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
audio:not([controls]){display:none;height:0}[hidden],template{display:none}
a{background-color:transparent}a:active,a:hover{outline-width:0}
abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none}
code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible}
button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
button,input{overflow:visible}button,select{text-transform:none}
button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}
button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
[type=checkbox],[type=radio]{padding:0}
[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
[type=search]{-webkit-appearance:textfield;outline-offset:-2px}
[type=search]::-webkit-search-decoration{-webkit-appearance:none}
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
/* End extract */
html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden}
h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit}
.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap}
.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}
.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
.w3-main,#main{transition:margin-left .4s}
.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
.w3-bar .w3-button{white-space:normal}
.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
.w3-responsive{display:block;overflow-x:auto}
.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
@media (max-width:1205px){.w3-auto{max-width:95%}}
@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}
.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
.w3-display-position{position:absolute}
.w3-circle{border-radius:50%}
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
.w3-left{float:left!important}.w3-right{float:right!important}
.w3-button:hover{color:#000!important;background-color:#ccc!important}
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
.w3-hover-none:hover{box-shadow:none!important}
/* Colors */
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}

View File

@ -1,5 +1,5 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "🪵", "emoji": "🪵",
"previous": "&3jabNEk6W2uolzTvfXX6fcWF50N3501vtgZ6ZxFVJ1s=.sha256" "previous": "&TIrBnpN3iz3O9L9MCCteAcVJZjA83EKdcfu4SCM76VE=.sha256"
} }

View File

@ -1,19 +1,11 @@
import * as commonmark from './commonmark.min.js'; import * as commonmark from './commonmark.min.js';
function escape(text) { function escape(text) {
return (text ?? '') return (text ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
} }
function escapeAttribute(text) { function escapeAttribute(text) {
return (text ?? '') return (text ?? '').replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;').replaceAll('"', '&quot;').replaceAll("'", '&#39;');
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
} }
export async function get_blog_message(id) { export async function get_blog_message(id) {
@ -21,7 +13,7 @@ export async function get_blog_message(id) {
await ssb.sqlAsync( await ssb.sqlAsync(
'SELECT author, timestamp, content FROM messages WHERE id = ?', 'SELECT author, timestamp, content FROM messages WHERE id = ?',
[id], [id],
function (row) { function(row) {
let content = JSON.parse(row.content); let content = JSON.parse(row.content);
message = { message = {
author: row.author, author: row.author,
@ -29,8 +21,7 @@ export async function get_blog_message(id) {
blog: content?.blog, blog: content?.blog,
title: content?.title, title: content?.title,
}; };
} });
);
if (message) { if (message) {
await ssb.sqlAsync( await ssb.sqlAsync(
` `
@ -43,17 +34,16 @@ export async function get_blog_message(id) {
ORDER BY sequence DESC LIMIT 1 ORDER BY sequence DESC LIMIT 1
`, `,
[message.author], [message.author],
function (row) { function(row) {
message.name = row.name; message.name = row.name;
} });
);
} }
return message; return message;
} }
export function markdown(md) { export function markdown(md) {
let reader = new commonmark.Parser(); let reader = new commonmark.Parser({safe: true});
let writer = new commonmark.HtmlRenderer({safe: true}); let writer = new commonmark.HtmlRenderer();
let parsed = reader.parse(md || ''); let parsed = reader.parse(md || '');
let walker = parsed.walker(); let walker = parsed.walker();
let event, node; let event, node;
@ -61,12 +51,8 @@ export function markdown(md) {
node = event.node; node = event.node;
if (event.entering) { if (event.entering) {
if (node.destination?.startsWith('&')) { if (node.destination?.startsWith('&')) {
node.destination = node.destination = '/' + node.destination + '/view?filename=' + node.firstChild?.literal;
'/' + node.destination + '/view?filename=' + node.firstChild?.literal; } else if (node.destination?.startsWith('@') || node.destination?.startsWith('%')) {
} else if (
node.destination?.startsWith('@') ||
node.destination?.startsWith('%')
) {
node.destination = '/~core/ssb/#' + escape(node.destination); node.destination = '/~core/ssb/#' + escape(node.destination);
} }
} }
@ -121,7 +107,7 @@ export function render_html(blogs) {
<h1>🪵Tilde Friends Blog</h1> <h1>🪵Tilde Friends Blog</h1>
<div style="font-size: xx-small; vertical-align: middle"><a href="/~cory/blog/atom">atom feed</a></div> <div style="font-size: xx-small; vertical-align: middle"><a href="/~cory/blog/atom">atom feed</a></div>
</div> </div>
${blogs.map((blog_post) => render_blog_post(blog_post)).join('\n')} ${blogs.map(blog_post => render_blog_post(blog_post)).join('\n')}
</body> </body>
</html>`; </html>`;
} }
@ -149,15 +135,14 @@ export function render_atom(blogs) {
<link href="${core.url}"/> <link href="${core.url}"/>
<id>${core.url}</id> <id>${core.url}</id>
<updated>${new Date().toString()}</updated> <updated>${new Date().toString()}</updated>
${blogs.map((blog_post) => render_blog_post_atom(blog_post)).join('\n')} ${blogs.map(blog_post => render_blog_post_atom(blog_post)).join('\n')}
</feed>`; </feed>`;
} }
export async function get_posts() { export async function get_posts() {
let blogs = []; let blogs = [];
let ids = await ssb.getIdentities(); let ids = await ssb.getIdentities();
await ssb.sqlAsync( await ssb.sqlAsync(`
`
WITH WITH
blogs AS ( blogs AS (
SELECT SELECT
@ -197,11 +182,8 @@ export async function get_posts() {
JOIN public ON public.author = blogs.author JOIN public ON public.author = blogs.author
LEFT OUTER JOIN names ON names.author = blogs.author LEFT OUTER JOIN names ON names.author = blogs.author
ORDER BY blogs.timestamp DESC LIMIT 20 ORDER BY blogs.timestamp DESC LIMIT 20
`, `, [JSON.stringify(ids)], function(row) {
[JSON.stringify(ids)], blogs.push(row);
function (row) { });
blogs.push(row);
}
);
return blogs; return blogs;
} }

File diff suppressed because one or more lines are too long

View File

@ -2,50 +2,30 @@ import * as blog from './blog.js';
async function main() { async function main() {
if (request.path.startsWith('%') && request.path.endsWith('.sha256')) { if (request.path.startsWith('%') && request.path.endsWith('.sha256')) {
let id = request.path.startsWith('%25') let id = request.path.startsWith('%25') ? '%' + request.path.substring(3) : request.path;
? '%' + request.path.substring(3)
: request.path;
let message = await blog.get_blog_message(id); let message = await blog.get_blog_message(id);
if (message) { if (message) {
respond({ respond({data: await blog.render_blog_post_html(message), content_type: 'text/html; charset=utf-8'});
data: await blog.render_blog_post_html(message),
content_type: 'text/html; charset=utf-8',
});
} else { } else {
respond({ respond({data: `Message ${id} not found.`, content_type: 'text/html; charset=utf-8'});
data: `Message ${id} not found.`,
content_type: 'text/html; charset=utf-8',
});
} }
} else if (request.path == 'atom') { } else if (request.path == 'atom') {
let blogs = await blog.get_posts(); let blogs = await blog.get_posts();
respond({ respond({data: blog.render_atom(blogs), content_type: 'application/atom+xml'});
data: blog.render_atom(blogs),
content_type: 'application/atom+xml',
});
} else { } else {
let blogs = await blog.get_posts(); let blogs = await blog.get_posts();
for (let blog_post of blogs) { for (let blog_post of blogs) {
let title = (blog_post.title || '').replaceAll(/\W/g, '_').toLowerCase(); let title = (blog_post.title || '').replaceAll(/\W/g, '_').toLowerCase();
if (request.path === title) { if (request.path === title) {
respond({ respond({data: await blog.render_blog_post_html(blog_post), content_type: 'text/html; charset=utf-8'});
data: await blog.render_blog_post_html(blog_post),
content_type: 'text/html; charset=utf-8',
});
return; return;
} }
} }
respond({ respond({data: blog.render_html(blogs), content_type: 'text/html; charset=utf-8'});
data: blog.render_html(blogs),
content_type: 'text/html; charset=utf-8',
});
} }
} }
main().catch(function (error) { main().catch(function(error) {
respond({ respond({data: `<!DOCTYPE html>
data: `<!DOCTYPE html> <pre style="color: #f00">${error.message}\n${error.stack}</pre>`, content_type: 'text/html'});
<pre style="color: #f00">${error.message}\n${error.stack}</pre>`,
content_type: 'text/html',
});
}); });

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,5 +1,4 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "💽", "emoji": "💽"
"previous": "&uQzkIe/Aj8yNhLKe3hEq+5fEJsGwIUx8NVBjJKwoV2U=.sha256"
} }

View File

@ -51,20 +51,7 @@ async function key_list(db) {
app.setDocument(doc); app.setDocument(doc);
} }
function load() { core.register('message', async function(message) {
if (core.user?.credentials?.session) {
database_list();
} else {
app.setDocument(`<!DOCTYPE html>
<html>
<body style="background: #888">
<h1>Must be signed in to examine databases.</h1>
</body>
</html>`);
}
}
core.register('message', async function (message) {
if (message.event == 'hashChange') { if (message.event == 'hashChange') {
let hash = message.hash.substring(1); let hash = message.hash.substring(1);
if (hash.startsWith(':shared:')) { if (hash.startsWith(':shared:')) {
@ -75,9 +62,9 @@ core.register('message', async function (message) {
} else if (hash.length) { } else if (hash.length) {
key_list(await database(hash.split(':').slice(1).join(':'))); key_list(await database(hash.split(':').slice(1).join(':')));
} else { } else {
load(); database_list();
} }
} }
}); });
load(); database_list();

View File

@ -1,5 +1,4 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "➡️", "emoji": "➡️"
"previous": "&YDDSzbRD8NFAykYlZnk4r4hAK5qXjT5LmKE6rhS1s+A=.sha256"
} }

View File

@ -2,7 +2,7 @@ let g_about_cache = {};
async function query(sql, args) { async function query(sql, args) {
let result = []; let result = [];
await ssb.sqlAsync(sql, args, function (row) { await ssb.sqlAsync(sql, args, function(row) {
result.push(row); result.push(row);
}); });
return result; return result;
@ -14,15 +14,14 @@ async function contacts_internal(id, last_row_id, following, max_row_id) {
result.blocking = result.blocking || {}; result.blocking = result.blocking || {};
let contacts = await query( let contacts = await query(
` `
SELECT json(content) AS content FROM messages SELECT content FROM messages
WHERE author = ? AND WHERE author = ? AND
rowid > ? AND rowid > ? AND
rowid <= ? AND rowid <= ? AND
json_extract(content, '$.type') = 'contact' json_extract(content, '$.type') = 'contact'
ORDER BY sequence ORDER BY sequence
`, `,
[id, last_row_id, max_row_id] [id, last_row_id, max_row_id]);
);
for (let row of contacts) { for (let row of contacts) {
let contact = JSON.parse(row.content); let contact = JSON.parse(row.content);
if (contact.following === true) { if (contact.following === true) {
@ -43,34 +42,15 @@ async function contact(id, last_row_id, following, max_row_id) {
return await contacts_internal(id, last_row_id, following, max_row_id); return await contacts_internal(id, last_row_id, following, max_row_id);
} }
async function following_deep_internal( async function following_deep_internal(ids, depth, blocking, last_row_id, following, max_row_id) {
ids, let contacts = await Promise.all([...new Set(ids)].map(x => contact(x, last_row_id, following, max_row_id)));
depth,
blocking,
last_row_id,
following,
max_row_id
) {
let contacts = await Promise.all(
[...new Set(ids)].map((x) => contact(x, last_row_id, following, max_row_id))
);
let result = {}; let result = {};
for (let i = 0; i < ids.length; i++) { for (let i = 0; i < ids.length; i++) {
let id = ids[i]; let id = ids[i];
let contact = contacts[i]; let contact = contacts[i];
let all_blocking = Object.assign({}, contact.blocking, blocking); let all_blocking = Object.assign({}, contact.blocking, blocking);
let found = Object.keys(contact.following).filter((y) => !all_blocking[y]); let found = Object.keys(contact.following).filter(y => !all_blocking[y]);
let deeper = let deeper = depth > 1 ? await following_deep_internal(found, depth - 1, all_blocking, last_row_id, following, max_row_id) : [];
depth > 1
? await following_deep_internal(
found,
depth - 1,
all_blocking,
last_row_id,
following,
max_row_id
)
: [];
result[id] = [id, ...found, ...deeper]; result[id] = [id, ...found, ...deeper];
} }
return [...new Set(Object.values(result).flat())]; return [...new Set(Object.values(result).flat())];
@ -88,22 +68,10 @@ async function following_deep(ids, depth, blocking) {
last_row_id: 0, last_row_id: 0,
}; };
} }
let max_row_id = ( let max_row_id = (await query(`
await query(
`
SELECT MAX(rowid) AS max_row_id FROM messages SELECT MAX(rowid) AS max_row_id FROM messages
`, `, []))[0].max_row_id;
[] let result = await following_deep_internal(ids, depth, blocking, cache.last_row_id, cache.following, max_row_id);
)
)[0].max_row_id;
let result = await following_deep_internal(
ids,
depth,
blocking,
cache.last_row_id,
cache.following,
max_row_id
);
cache.last_row_id = max_row_id; cache.last_row_id = max_row_id;
let store = JSON.stringify(cache); let store = JSON.stringify(cache);
await db.set('following', store); await db.set('following', store);
@ -122,15 +90,13 @@ async function fetch_about(db, ids, users) {
}; };
} }
let max_row_id = 0; let max_row_id = 0;
await ssb.sqlAsync( await ssb.sqlAsync(`
`
SELECT MAX(rowid) AS max_row_id FROM messages SELECT MAX(rowid) AS max_row_id FROM messages
`, `,
[], [],
function (row) { function(row) {
max_row_id = row.max_row_id; max_row_id = row.max_row_id;
} });
);
for (let id of Object.keys(cache.about)) { for (let id of Object.keys(cache.about)) {
if (ids.indexOf(id) == -1) { if (ids.indexOf(id) == -1) {
delete cache.about[id]; delete cache.about[id];
@ -163,21 +129,17 @@ async function fetch_about(db, ids, users) {
ORDER BY messages.author, messages.sequence ORDER BY messages.author, messages.sequence
`, `,
[ [
JSON.stringify(ids.filter((id) => cache.about[id])), JSON.stringify(ids.filter(id => cache.about[id])),
JSON.stringify(ids.filter((id) => !cache.about[id])), JSON.stringify(ids.filter(id => !cache.about[id])),
cache.last_row_id, cache.last_row_id,
max_row_id, max_row_id,
] ]);
);
for (let about of abouts) { for (let about of abouts) {
let content = JSON.parse(about.content); let content = JSON.parse(about.content);
if (content.about === about.author) { if (content.about === about.author) {
delete content.type; delete content.type;
delete content.about; delete content.about;
cache.about[about.author] = Object.assign( cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content);
cache.about[about.author] || {},
content
);
} }
} }
cache.last_row_id = max_row_id; cache.last_row_id = max_row_id;
@ -189,18 +151,62 @@ async function fetch_about(db, ids, users) {
return Object.assign({}, users); return Object.assign({}, users);
} }
async function getAbout(db, id) {
if (g_about_cache[id]) {
return g_about_cache[id];
}
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",
[id, f.sequence],
function(row) {
f.sequence = row.sequence;
if (row.content) {
let about = {};
try {
about = JSON.parse(row.content);
} 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);
}
g_about_cache[id] = f.about;
return f.about;
}
async function getSize(db, id) { async function getSize(db, id) {
let size = 0; let size = 0;
await ssb.sqlAsync( await ssb.sqlAsync(
'SELECT (LENGTH(author) * COUNT(*) + SUM(LENGTH(content)) + SUM(LENGTH(id))) AS size FROM messages WHERE author = ?1', "SELECT (LENGTH(author) * COUNT(*) + SUM(LENGTH(content)) + SUM(LENGTH(id))) AS size FROM messages WHERE author = ?1",
[id], [id],
function (row) { function (row) {
size += row.size; size += row.size;
} });
);
return size; return size;
} }
async function getSizes(ids) { async function getSizes(ids) {
let sizes = {}; let sizes = {};
await ssb.sqlAsync( await ssb.sqlAsync(
@ -215,8 +221,7 @@ async function getSizes(ids) {
[JSON.stringify(ids)], [JSON.stringify(ids)],
function (row) { function (row) {
sizes[row.author] = row.size; sizes[row.author] = row.size;
} });
);
return sizes; return sizes;
} }
@ -236,10 +241,7 @@ function niceSize(bytes) {
} }
function escape(value) { function escape(value) {
return value return value.replaceAll('&', '&amp;').replaceAll('<', '&lt;').replaceAll('>', '&gt;');
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;');
} }
async function main() { async function main() {
@ -247,27 +249,19 @@ async function main() {
let db = await database('ssb'); let db = await database('ssb');
let whoami = await ssb.getIdentities(); let whoami = await ssb.getIdentities();
let tree = ''; let tree = '';
await app.setDocument( await app.setDocument(`<pre style="color: #fff">Enumerating followed users...</pre>`);
`<pre style="color: #fff">Enumerating followed users...</pre>`
);
let following = await following_deep(whoami, 2, {}); let following = await following_deep(whoami, 2, {});
await app.setDocument( await app.setDocument(`<pre style="color: #fff">Getting names and sizes...</pre>`);
`<pre style="color: #fff">Getting names and sizes...</pre>`
);
let [about, sizes] = await Promise.all([ let [about, sizes] = await Promise.all([
fetch_about(db, following, {}), fetch_about(db, following, {}),
getSizes(following), getSizes(following),
]); ]);
await app.setDocument(`<pre style="color: #fff">Finishing...</pre>`); await app.setDocument(`<pre style="color: #fff">Finishing...</pre>`);
following.sort((a, b) => (sizes[b] ?? 0) - (sizes[a] ?? 0)); following.sort((a, b) => ((sizes[b] ?? 0) - (sizes[a] ?? 0)));
for (let id of following) { for (let id of following) {
tree += `<li><a href="/~core/ssb/#${id}">${escape(about[id]?.name ?? id)}</a> ${niceSize(sizes[id] ?? 0)}</li>\n`; tree += `<li><a href="/~core/ssb/#${id}">${escape(about[id]?.name ?? id)}</a> ${niceSize(sizes[id] ?? 0)}</li>\n`;
} }
await app.setDocument( await app.setDocument('<!DOCTYPE html>\n<html>\n<body style="color: #fff"><h1>Following</h1>\n<ul>' + tree + '</ul>\n</body>\n</html>');
'<!DOCTYPE html>\n<html>\n<body style="color: #fff"><h1>Following</h1>\n<ul>' +
tree +
'</ul>\n</body>\n</html>'
);
} }
main(); main();

5
apps/gg.json Normal file
View File

@ -0,0 +1,5 @@
{
"type": "tildefriends-app",
"emoji": "🗺",
"previous": "&0XSp+xdQwVtQ88bXzvWdH15Ex63hv5zUKTa4zx7HBGM=.sha256"
}

80
apps/gg/app.js Normal file
View File

@ -0,0 +1,80 @@
import * as tfrpc from '/tfrpc.js';
import * as strava from './strava.js';
let g_database;
let g_shared_database;
tfrpc.register(async function createIdentity() {
return ssb.createIdentity();
});
tfrpc.register(async function appendMessage(id, message) {
print('APPEND', JSON.stringify(message));
return ssb.appendMessageWithIdentity(id, message);
});
tfrpc.register(function url() {
return core.url;
});
tfrpc.register(async function getUser() {
return core.user;
});
tfrpc.register(function getIdentities() {
return ssb.getIdentities();
});
tfrpc.register(async function databaseGet(key) {
return g_database ? g_database.get(key) : undefined;
});
tfrpc.register(async function databaseSet(key, value) {
return g_database ? g_database.set(key, value) : undefined;
});
tfrpc.register(async function databaseRemove(key, value) {
return g_database ? g_database.remove(key, value) : undefined;
});
tfrpc.register(async function sharedDatabaseGet(key) {
return g_shared_database ? g_shared_database.get(key) : undefined;
});
tfrpc.register(async function sharedDatabaseSet(key, value) {
return g_shared_database ? g_shared_database.set(key, value) : undefined;
});
tfrpc.register(async function sharedDatabaseRemove(key, value) {
return g_shared_database ? g_shared_database.remove(key, value) : undefined;
});
tfrpc.register(async function query(sql, args) {
let result = [];
await ssb.sqlAsync(sql, args, function callback(row) {
result.push(row);
});
return result;
});
tfrpc.register(async function store_blob(blob) {
if (typeof(blob) == 'string') {
blob = utf8Encode(blob);
}
if (Array.isArray(blob)) {
blob = Uint8Array.from(blob);
}
return await ssb.blobStore(blob);
});
tfrpc.register(async function get_blob(id) {
return utf8Decode(await ssb.blobGet(id));
});
tfrpc.register(strava.refresh_token);
async function main() {
g_shared_database = await shared_database('state');
if (core.user.credentials?.session?.name) {
g_database = await database('state');
}
let attempt;
if (core.user.credentials?.session?.name) {
let shared_db = await shared_database('state');
attempt = await shared_db.get(core.user.credentials.session.name);
}
app.setDocument(utf8Decode(getFile('index.html')).replace('${data}', JSON.stringify({
attempt: attempt,
state: core.user?.credentials?.session?.name,
})));
}
main();

1
apps/gg/emojis.json Normal file

File diff suppressed because one or more lines are too long

81
apps/gg/gpx.js Normal file
View File

@ -0,0 +1,81 @@
function xml_parse(xml) {
let result;
let path = [];
let tag_begin;
let text_begin;
for (let i = 0; i < xml.length; i++) {
let c = xml.charAt(i);
if (!tag_begin && c == '<') {
if (i > text_begin && path.length) {
let value = xml.substring(text_begin, i);
if (!/^\s*$/.test(value)) {
path[path.length - 1].value = value;
}
}
tag_begin = i + 1;
} else if (tag_begin && c == '>') {
let tag = xml.substring(tag_begin, i).trim();
if (tag.startsWith('?') && tag.endsWith('?')) {
/* Ignore directives. */
} else if (tag.startsWith('/')) {
path.pop();
} else {
let parts = tag.split(' ');
let attributes = {};
for (let j = 1; j < parts.length; j++) {
let eq = parts[j].indexOf('=');
let value = parts[j].substring(eq + 1);
if (value.startsWith('"') && value.endsWith('"')) {
value = value.substring(1, value.length - 1);
}
attributes[parts[j].substring(0, eq)] = value;
}
let next = {name: parts[0], children: [], attributes: attributes};
if (path.length) {
path[path.length - 1].children.push(next);
} else {
result = next;
}
if (!tag.endsWith('/')) {
path.push(next);
}
}
tag_begin = undefined;
text_begin = i + 1;
}
}
return result;
}
function* xml_each(node, name) {
for (let child of node.children) {
if (child.name == name) {
yield child;
}
}
}
export function gpx_parse(xml) {
let result = {segments: []};
let tree = xml_parse(xml);
if (tree?.name == 'gpx') {
for (let trk of xml_each(tree, 'trk')) {
for (let trkseg of xml_each(trk, 'trkseg')) {
let segment = [];
for (let trkpt of xml_each(trkseg, 'trkpt')) {
segment.push({lat: parseFloat(trkpt.attributes.lat), lon: parseFloat(trkpt.attributes.lon)});
}
result.segments.push(segment);
}
}
}
for (let metadata of xml_each(tree, 'metadata')) {
for (let link of xml_each(metadata, 'link')) {
result.link = link.attributes.href;
}
for (let time of xml_each(metadata, 'time')) {
result.time = time.value;
}
}
return result;
}

21
apps/gg/handler.js Normal file
View File

@ -0,0 +1,21 @@
import * as strava from './strava.js';
async function main() {
print('handler running');
let r = await strava.authorization_code(request.query.code);
print('state =', request.query.state);
print('body = ', r.body);
if (request.query.state && r.body) {
let shared_db = await shared_database('state');
await shared_db.set(request.query.state, utf8Decode(r.body));
}
await respond({
data: r.body,
content_type: 'text/plain',
headers: {
Location: 'https://tildefriends.net/~cory/gg/',
},
status_code: 307,
});
}
main();

14
apps/gg/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html style="width: 100%; height: 100%; margin: 0; padding: 0">
<head>
<script>window.litDisableBundleWarning = true;</script>
<script>
let g_data = ${data};
</script>
<script src="script.js" type="module"></script>
<script src="leaflet.js"></script>
</head>
<body style="color: #fff; display: flex; flex-flow: column; height: 100%; width: 100%; margin: 0; padding: 0">
<gg-app style="width: 100%; height: 100%" id="ggapp"></gg-app>
</body>
</html>

661
apps/gg/leaflet.css Normal file
View File

@ -0,0 +1,661 @@
/* required styles */
.leaflet-pane,
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-tile-container,
.leaflet-pane > svg,
.leaflet-pane > canvas,
.leaflet-zoom-box,
.leaflet-image-layer,
.leaflet-layer {
position: absolute;
left: 0;
top: 0;
}
.leaflet-container {
overflow: hidden;
}
.leaflet-tile,
.leaflet-marker-icon,
.leaflet-marker-shadow {
-webkit-user-select: none;
-moz-user-select: none;
user-select: none;
-webkit-user-drag: none;
}
/* Prevents IE11 from highlighting tiles in blue */
.leaflet-tile::selection {
background: transparent;
}
/* Safari renders non-retina tile on retina better with this, but Chrome is worse */
.leaflet-safari .leaflet-tile {
image-rendering: -webkit-optimize-contrast;
}
/* hack that prevents hw layers "stretching" when loading new tiles */
.leaflet-safari .leaflet-tile-container {
width: 1600px;
height: 1600px;
-webkit-transform-origin: 0 0;
}
.leaflet-marker-icon,
.leaflet-marker-shadow {
display: block;
}
/* .leaflet-container svg: reset svg max-width decleration shipped in Joomla! (joomla.org) 3.x */
/* .leaflet-container img: map is broken in FF if you have max-width: 100% on tiles */
.leaflet-container .leaflet-overlay-pane svg {
max-width: none !important;
max-height: none !important;
}
.leaflet-container .leaflet-marker-pane img,
.leaflet-container .leaflet-shadow-pane img,
.leaflet-container .leaflet-tile-pane img,
.leaflet-container img.leaflet-image-layer,
.leaflet-container .leaflet-tile {
max-width: none !important;
max-height: none !important;
width: auto;
padding: 0;
}
.leaflet-container img.leaflet-tile {
/* See: https://bugs.chromium.org/p/chromium/issues/detail?id=600120 */
mix-blend-mode: plus-lighter;
}
.leaflet-container.leaflet-touch-zoom {
-ms-touch-action: pan-x pan-y;
touch-action: pan-x pan-y;
}
.leaflet-container.leaflet-touch-drag {
-ms-touch-action: pinch-zoom;
/* Fallback for FF which doesn't support pinch-zoom */
touch-action: none;
touch-action: pinch-zoom;
}
.leaflet-container.leaflet-touch-drag.leaflet-touch-zoom {
-ms-touch-action: none;
touch-action: none;
}
.leaflet-container {
-webkit-tap-highlight-color: transparent;
}
.leaflet-container a {
-webkit-tap-highlight-color: rgba(51, 181, 229, 0.4);
}
.leaflet-tile {
filter: inherit;
visibility: hidden;
}
.leaflet-tile-loaded {
visibility: inherit;
}
.leaflet-zoom-box {
width: 0;
height: 0;
-moz-box-sizing: border-box;
box-sizing: border-box;
z-index: 800;
}
/* workaround for https://bugzilla.mozilla.org/show_bug.cgi?id=888319 */
.leaflet-overlay-pane svg {
-moz-user-select: none;
}
.leaflet-pane { z-index: 400; }
.leaflet-tile-pane { z-index: 200; }
.leaflet-overlay-pane { z-index: 400; }
.leaflet-shadow-pane { z-index: 500; }
.leaflet-marker-pane { z-index: 600; }
.leaflet-tooltip-pane { z-index: 650; }
.leaflet-popup-pane { z-index: 700; }
.leaflet-map-pane canvas { z-index: 100; }
.leaflet-map-pane svg { z-index: 200; }
.leaflet-vml-shape {
width: 1px;
height: 1px;
}
.lvml {
behavior: url(#default#VML);
display: inline-block;
position: absolute;
}
/* control positioning */
.leaflet-control {
position: relative;
z-index: 800;
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
.leaflet-top,
.leaflet-bottom {
position: absolute;
z-index: 1000;
pointer-events: none;
}
.leaflet-top {
top: 0;
}
.leaflet-right {
right: 0;
}
.leaflet-bottom {
bottom: 0;
}
.leaflet-left {
left: 0;
}
.leaflet-control {
float: left;
clear: both;
}
.leaflet-right .leaflet-control {
float: right;
}
.leaflet-top .leaflet-control {
margin-top: 10px;
}
.leaflet-bottom .leaflet-control {
margin-bottom: 10px;
}
.leaflet-left .leaflet-control {
margin-left: 10px;
}
.leaflet-right .leaflet-control {
margin-right: 10px;
}
/* zoom and fade animations */
.leaflet-fade-anim .leaflet-popup {
opacity: 0;
-webkit-transition: opacity 0.2s linear;
-moz-transition: opacity 0.2s linear;
transition: opacity 0.2s linear;
}
.leaflet-fade-anim .leaflet-map-pane .leaflet-popup {
opacity: 1;
}
.leaflet-zoom-animated {
-webkit-transform-origin: 0 0;
-ms-transform-origin: 0 0;
transform-origin: 0 0;
}
svg.leaflet-zoom-animated {
will-change: transform;
}
.leaflet-zoom-anim .leaflet-zoom-animated {
-webkit-transition: -webkit-transform 0.25s cubic-bezier(0,0,0.25,1);
-moz-transition: -moz-transform 0.25s cubic-bezier(0,0,0.25,1);
transition: transform 0.25s cubic-bezier(0,0,0.25,1);
}
.leaflet-zoom-anim .leaflet-tile,
.leaflet-pan-anim .leaflet-tile {
-webkit-transition: none;
-moz-transition: none;
transition: none;
}
.leaflet-zoom-anim .leaflet-zoom-hide {
visibility: hidden;
}
/* cursors */
.leaflet-interactive {
cursor: pointer;
}
.leaflet-grab {
cursor: -webkit-grab;
cursor: -moz-grab;
cursor: grab;
}
.leaflet-crosshair,
.leaflet-crosshair .leaflet-interactive {
cursor: crosshair;
}
.leaflet-popup-pane,
.leaflet-control {
cursor: auto;
}
.leaflet-dragging .leaflet-grab,
.leaflet-dragging .leaflet-grab .leaflet-interactive,
.leaflet-dragging .leaflet-marker-draggable {
cursor: move;
cursor: -webkit-grabbing;
cursor: -moz-grabbing;
cursor: grabbing;
}
/* marker & overlays interactivity */
.leaflet-marker-icon,
.leaflet-marker-shadow,
.leaflet-image-layer,
.leaflet-pane > svg path,
.leaflet-tile-container {
pointer-events: none;
}
.leaflet-marker-icon.leaflet-interactive,
.leaflet-image-layer.leaflet-interactive,
.leaflet-pane > svg path.leaflet-interactive,
svg.leaflet-image-layer.leaflet-interactive path {
pointer-events: visiblePainted; /* IE 9-10 doesn't have auto */
pointer-events: auto;
}
/* visual tweaks */
.leaflet-container {
background: #ddd;
outline-offset: 1px;
}
.leaflet-container a {
color: #0078A8;
}
.leaflet-zoom-box {
border: 2px dotted #38f;
background: rgba(255,255,255,0.5);
}
/* general typography */
.leaflet-container {
font-family: "Helvetica Neue", Arial, Helvetica, sans-serif;
font-size: 12px;
font-size: 0.75rem;
line-height: 1.5;
}
/* general toolbar styles */
.leaflet-bar {
box-shadow: 0 1px 5px rgba(0,0,0,0.65);
border-radius: 4px;
}
.leaflet-bar a {
background-color: #fff;
border-bottom: 1px solid #ccc;
width: 26px;
height: 26px;
line-height: 26px;
display: block;
text-align: center;
text-decoration: none;
color: black;
}
.leaflet-bar a,
.leaflet-control-layers-toggle {
background-position: 50% 50%;
background-repeat: no-repeat;
display: block;
}
.leaflet-bar a:hover,
.leaflet-bar a:focus {
background-color: #f4f4f4;
}
.leaflet-bar a:first-child {
border-top-left-radius: 4px;
border-top-right-radius: 4px;
}
.leaflet-bar a:last-child {
border-bottom-left-radius: 4px;
border-bottom-right-radius: 4px;
border-bottom: none;
}
.leaflet-bar a.leaflet-disabled {
cursor: default;
background-color: #f4f4f4;
color: #bbb;
}
.leaflet-touch .leaflet-bar a {
width: 30px;
height: 30px;
line-height: 30px;
}
.leaflet-touch .leaflet-bar a:first-child {
border-top-left-radius: 2px;
border-top-right-radius: 2px;
}
.leaflet-touch .leaflet-bar a:last-child {
border-bottom-left-radius: 2px;
border-bottom-right-radius: 2px;
}
/* zoom control */
.leaflet-control-zoom-in,
.leaflet-control-zoom-out {
font: bold 18px 'Lucida Console', Monaco, monospace;
text-indent: 1px;
}
.leaflet-touch .leaflet-control-zoom-in, .leaflet-touch .leaflet-control-zoom-out {
font-size: 22px;
}
/* layers control */
.leaflet-control-layers {
box-shadow: 0 1px 5px rgba(0,0,0,0.4);
background: #fff;
border-radius: 5px;
}
.leaflet-control-layers-toggle {
background-image: url(images/layers.png);
width: 36px;
height: 36px;
}
.leaflet-retina .leaflet-control-layers-toggle {
background-image: url(images/layers-2x.png);
background-size: 26px 26px;
}
.leaflet-touch .leaflet-control-layers-toggle {
width: 44px;
height: 44px;
}
.leaflet-control-layers .leaflet-control-layers-list,
.leaflet-control-layers-expanded .leaflet-control-layers-toggle {
display: none;
}
.leaflet-control-layers-expanded .leaflet-control-layers-list {
display: block;
position: relative;
}
.leaflet-control-layers-expanded {
padding: 6px 10px 6px 6px;
color: #333;
background: #fff;
}
.leaflet-control-layers-scrollbar {
overflow-y: scroll;
overflow-x: hidden;
padding-right: 5px;
}
.leaflet-control-layers-selector {
margin-top: 2px;
position: relative;
top: 1px;
}
.leaflet-control-layers label {
display: block;
font-size: 13px;
font-size: 1.08333em;
}
.leaflet-control-layers-separator {
height: 0;
border-top: 1px solid #ddd;
margin: 5px -10px 5px -6px;
}
/* Default icon URLs */
.leaflet-default-icon-path { /* used only in path-guessing heuristic, see L.Icon.Default */
background-image: url(images/marker-icon.png);
}
/* attribution and scale controls */
.leaflet-container .leaflet-control-attribution {
background: #fff;
background: rgba(255, 255, 255, 0.8);
margin: 0;
}
.leaflet-control-attribution,
.leaflet-control-scale-line {
padding: 0 5px;
color: #333;
line-height: 1.4;
}
.leaflet-control-attribution a {
text-decoration: none;
}
.leaflet-control-attribution a:hover,
.leaflet-control-attribution a:focus {
text-decoration: underline;
}
.leaflet-attribution-flag {
display: inline !important;
vertical-align: baseline !important;
width: 1em;
height: 0.6669em;
}
.leaflet-left .leaflet-control-scale {
margin-left: 5px;
}
.leaflet-bottom .leaflet-control-scale {
margin-bottom: 5px;
}
.leaflet-control-scale-line {
border: 2px solid #777;
border-top: none;
line-height: 1.1;
padding: 2px 5px 1px;
white-space: nowrap;
-moz-box-sizing: border-box;
box-sizing: border-box;
background: rgba(255, 255, 255, 0.8);
text-shadow: 1px 1px #fff;
}
.leaflet-control-scale-line:not(:first-child) {
border-top: 2px solid #777;
border-bottom: none;
margin-top: -2px;
}
.leaflet-control-scale-line:not(:first-child):not(:last-child) {
border-bottom: 2px solid #777;
}
.leaflet-touch .leaflet-control-attribution,
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
box-shadow: none;
}
.leaflet-touch .leaflet-control-layers,
.leaflet-touch .leaflet-bar {
border: 2px solid rgba(0,0,0,0.2);
background-clip: padding-box;
}
/* popup */
.leaflet-popup {
position: absolute;
text-align: center;
margin-bottom: 20px;
}
.leaflet-popup-content-wrapper {
padding: 1px;
text-align: left;
border-radius: 12px;
}
.leaflet-popup-content {
margin: 13px 24px 13px 20px;
line-height: 1.3;
font-size: 13px;
font-size: 1.08333em;
min-height: 1px;
}
.leaflet-popup-content p {
margin: 17px 0;
margin: 1.3em 0;
}
.leaflet-popup-tip-container {
width: 40px;
height: 20px;
position: absolute;
left: 50%;
margin-top: -1px;
margin-left: -20px;
overflow: hidden;
pointer-events: none;
}
.leaflet-popup-tip {
width: 17px;
height: 17px;
padding: 1px;
margin: -10px auto 0;
pointer-events: auto;
-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);
}
.leaflet-popup-content-wrapper,
.leaflet-popup-tip {
background: white;
color: #333;
box-shadow: 0 3px 14px rgba(0,0,0,0.4);
}
.leaflet-container a.leaflet-popup-close-button {
position: absolute;
top: 0;
right: 0;
border: none;
text-align: center;
width: 24px;
height: 24px;
font: 16px/24px Tahoma, Verdana, sans-serif;
color: #757575;
text-decoration: none;
background: transparent;
}
.leaflet-container a.leaflet-popup-close-button:hover,
.leaflet-container a.leaflet-popup-close-button:focus {
color: #585858;
}
.leaflet-popup-scrolled {
overflow: auto;
}
.leaflet-oldie .leaflet-popup-content-wrapper {
-ms-zoom: 1;
}
.leaflet-oldie .leaflet-popup-tip {
width: 24px;
margin: 0 auto;
-ms-filter: "progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678)";
filter: progid:DXImageTransform.Microsoft.Matrix(M11=0.70710678, M12=0.70710678, M21=-0.70710678, M22=0.70710678);
}
.leaflet-oldie .leaflet-control-zoom,
.leaflet-oldie .leaflet-control-layers,
.leaflet-oldie .leaflet-popup-content-wrapper,
.leaflet-oldie .leaflet-popup-tip {
border: 1px solid #999;
}
/* div icon */
.leaflet-div-icon {
background: #fff;
border: 1px solid #666;
}
/* Tooltip */
/* Base styles for the element that has a tooltip */
.leaflet-tooltip {
position: absolute;
padding: 6px;
background-color: #fff;
border: 1px solid #fff;
border-radius: 3px;
color: #222;
white-space: nowrap;
-webkit-user-select: none;
-moz-user-select: none;
-ms-user-select: none;
user-select: none;
pointer-events: none;
box-shadow: 0 1px 3px rgba(0,0,0,0.4);
}
.leaflet-tooltip.leaflet-interactive {
cursor: pointer;
pointer-events: auto;
}
.leaflet-tooltip-top:before,
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
position: absolute;
pointer-events: none;
border: 6px solid transparent;
background: transparent;
content: "";
}
/* Directions */
.leaflet-tooltip-bottom {
margin-top: 6px;
}
.leaflet-tooltip-top {
margin-top: -6px;
}
.leaflet-tooltip-bottom:before,
.leaflet-tooltip-top:before {
left: 50%;
margin-left: -6px;
}
.leaflet-tooltip-top:before {
bottom: 0;
margin-bottom: -12px;
border-top-color: #fff;
}
.leaflet-tooltip-bottom:before {
top: 0;
margin-top: -12px;
margin-left: -6px;
border-bottom-color: #fff;
}
.leaflet-tooltip-left {
margin-left: -6px;
}
.leaflet-tooltip-right {
margin-left: 6px;
}
.leaflet-tooltip-left:before,
.leaflet-tooltip-right:before {
top: 50%;
margin-top: -6px;
}
.leaflet-tooltip-left:before {
right: 0;
margin-right: -12px;
border-left-color: #fff;
}
.leaflet-tooltip-right:before {
left: 0;
margin-left: -12px;
border-right-color: #fff;
}
/* Printing */
@media print {
/* Prevent printers from removing background-images of controls. */
.leaflet-control {
-webkit-print-color-adjust: exact;
print-color-adjust: exact;
}
}

6
apps/gg/leaflet.js Normal file

File diff suppressed because one or more lines are too long

1
apps/gg/leaflet.js.map Normal file

File diff suppressed because one or more lines are too long

120
apps/gg/lit-all.min.js vendored Normal file

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

158
apps/gg/polyline.js Normal file
View File

@ -0,0 +1,158 @@
/**
* Based off of [the offical Google document](https://developers.google.com/maps/documentation/utilities/polylinealgorithm)
*
* Some parts from [this implementation](http://facstaff.unca.edu/mcmcclur/GoogleMaps/EncodePolyline/PolylineEncoder.js)
* by [Mark McClure](http://facstaff.unca.edu/mcmcclur/)
*
* @module polyline
*/
var polyline = {};
function py2_round(value) {
// Google's polyline algorithm uses the same rounding strategy as Python 2, which is different from JS for negative values
return Math.floor(Math.abs(value) + 0.5) * (value >= 0 ? 1 : -1);
}
function encode(current, previous, factor) {
current = py2_round(current * factor);
previous = py2_round(previous * factor);
var coordinate = (current - previous) * 2;
if (coordinate < 0) {
coordinate = -coordinate - 1
}
var output = '';
while (coordinate >= 0x20) {
output += String.fromCharCode((0x20 | (coordinate & 0x1f)) + 63);
coordinate /= 32;
}
output += String.fromCharCode((coordinate | 0) + 63);
return output;
}
/**
* Decodes to a [latitude, longitude] coordinates array.
*
* This is adapted from the implementation in Project-OSRM.
*
* @param {String} str
* @param {Number} precision
* @returns {Array}
*
* @see https://github.com/Project-OSRM/osrm-frontend/blob/master/WebContent/routing/OSRM.RoutingGeometry.js
*/
polyline.decode = function(str, precision) {
var index = 0,
lat = 0,
lng = 0,
coordinates = [],
shift = 0,
result = 0,
byte = null,
latitude_change,
longitude_change,
factor = Math.pow(10, Number.isInteger(precision) ? precision : 5);
// Coordinates have variable length when encoded, so just keep
// track of whether we've hit the end of the string. In each
// loop iteration, a single coordinate is decoded.
while (index < str.length) {
// Reset shift, result, and byte
byte = null;
shift = 1;
result = 0;
do {
byte = str.charCodeAt(index++) - 63;
result += (byte & 0x1f) * shift;
shift *= 32;
} while (byte >= 0x20);
latitude_change = (result & 1) ? ((-result - 1) / 2) : (result / 2);
shift = 1;
result = 0;
do {
byte = str.charCodeAt(index++) - 63;
result += (byte & 0x1f) * shift;
shift *= 32;
} while (byte >= 0x20);
longitude_change = (result & 1) ? ((-result - 1) / 2) : (result / 2);
lat += latitude_change;
lng += longitude_change;
coordinates.push([lat / factor, lng / factor]);
}
return coordinates;
};
/**
* Encodes the given [latitude, longitude] coordinates array.
*
* @param {Array.<Array.<Number>>} coordinates
* @param {Number} precision
* @returns {String}
*/
polyline.encode = function(coordinates, precision) {
if (!coordinates.length) { return ''; }
var factor = Math.pow(10, Number.isInteger(precision) ? precision : 5),
output = encode(coordinates[0][0], 0, factor) + encode(coordinates[0][1], 0, factor);
for (var i = 1; i < coordinates.length; i++) {
var a = coordinates[i], b = coordinates[i - 1];
output += encode(a[0], b[0], factor);
output += encode(a[1], b[1], factor);
}
return output;
};
function flipped(coords) {
var flipped = [];
for (var i = 0; i < coords.length; i++) {
var coord = coords[i].slice();
flipped.push([coord[1], coord[0]]);
}
return flipped;
}
/**
* Encodes a GeoJSON LineString feature/geometry.
*
* @param {Object} geojson
* @param {Number} precision
* @returns {String}
*/
polyline.fromGeoJSON = function(geojson, precision) {
if (geojson && geojson.type === 'Feature') {
geojson = geojson.geometry;
}
if (!geojson || geojson.type !== 'LineString') {
throw new Error('Input must be a GeoJSON LineString');
}
return polyline.encode(flipped(geojson.coordinates), precision);
};
/**
* Decodes to a GeoJSON LineString geometry.
*
* @param {String} str
* @param {Number} precision
* @returns {Object}
*/
polyline.toGeoJSON = function(str, precision) {
var coords = polyline.decode(str, precision);
return {
type: 'LineString',
coordinates: flipped(coords)
};
};
let polyline_decode = polyline.decode;
export { polyline_decode as decode };

807
apps/gg/script.js Normal file
View File

@ -0,0 +1,807 @@
import {LitElement, html, unsafeHTML, css, guard, until} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
import * as polyline from './polyline.js';
import {gpx_parse} from './gpx.js';
const k_client_id = '28276';
const k_redirect_url = 'https://tildefriends.net/~cory/gg/login';
const k_color_snow = [128, 128, 255, 255];
const k_color_ice = [160, 160, 255, 255];
const k_color_water = [0, 0, 255, 255];
const k_color_dirt = [128, 129, 130, 255];
const k_color_pavement = [32, 32, 32, 255];
const k_color_grass = [0, 255, 0, 255];
const k_color_default = [128, 128, 128, 255];
const k_store = {
'🦞': 15,
'🛶': 10,
'🏠': 10,
'⛰': 10,
'🐠': 10,
};
const k_marker_snap = {x: 5, y: 4};
class GgAppElement extends LitElement {
static get properties() {
return {
user: {type: Object},
strava: {type: Object},
activities: {type: Array},
activity: {type: Object},
world: {type: Object},
whoami: {type: String},
status: {type: Object},
tab: {type: String},
url: {type: String},
currency: {type: Number},
to_build: {type: String},
emoji_of_the_day: {type: String},
};
}
constructor() {
super();
this.activities = [];
this.activity = {};
this.loaded_activities = [];
this.placed_emojis = [];
this.strava = {};
this.min_lat = Number.MAX_VALUE;
this.min_lon = Number.MAX_VALUE;
this.max_lat = -Number.MAX_VALUE;
this.max_lon = -Number.MAX_VALUE;
this.focus = undefined;
this.status = undefined;
this.tab = 'map';
this.load().catch(function(e) {
console.log('load error', e);
});
this.to_build = '🏠';
}
async load() {
console.log('load');
let emojis = await (await fetch('emojis.json')).json();
emojis = Object.values(emojis).map(x => Object.values(x)).flat();
let today = new Date();
let date_index = today.getYear() * 356 + today.getMonth() * 31 + today.getDate();
this.emoji_of_the_day = emojis[(date_index * 123457) % emojis.length];
this.user = await tfrpc.rpc.getUser();
this.url = (await tfrpc.rpc.url()).split('?')[0];
try {
await this.update_credentials();
} catch (e) {
console.log('update_credentials failed', e);
}
try {
await this.update_activities();
} catch (e) {
console.log('update_activities failed', e);
}
await this.acquire_ssb_identity();
if (this.whoami && this.activities?.length) {
await this.sync_activities();
}
await this.get_activities_from_ssb();
}
/* https://gist.github.com/jcouyang/632709f30e12a7879a73e9e132c0d56b?permalink_comment_id=3591045#gistcomment-3591045 */
async promise_all(promises, max_concurrent) {
let index = 0;
let results = [];
async function exec_thread() {
while (index < promises.length) {
const current = index++;
results[current] = await promises[current];
}
}
const threads = [];
for (let thread = 0; thread < max_concurrent; thread++) {
threads.push(exec_thread());
}
await Promise.all(threads);
return results;
}
async get_activities_from_ssb() {
this.status = {text: 'loading activities'};
this.loaded_activities = [];
let rows = await tfrpc.rpc.query(`
SELECT messages.author, json_extract(mention.value, '$.link') AS blob_id
FROM messages_fts('"gg-activity"')
JOIN messages ON messages.rowid = messages_fts.rowid,
json_each(messages.content, '$.mentions') as mention
WHERE json_extract(messages.content, '$.type') = 'gg-activity' AND
json_extract(mention.value, '$.name') = 'activity_data'
ORDER BY messages.timestamp DESC
`, []);
this.status = {text: 'loading activity data'};
let authors = rows.map(x => x.author);
let blobs = await this.promise_all(rows.map(x => tfrpc.rpc.get_blob(x.blob_id)), 8);
this.status = {text: 'processing activity data'};
for (let [index, blob] of blobs.entries()) {
let activity;
try {
activity = JSON.parse(blob);
} catch {
activity = gpx_parse(blob);
}
if (activity) {
activity.author = authors[index];
this.loaded_activities.push(activity);
}
}
this.status = {text: 'calculating balance'};
rows = await tfrpc.rpc.query(`
SELECT count(*) AS currency FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-activity'
`, [this.whoami]);
let currency = rows[0].currency;
rows = await tfrpc.rpc.query(`
SELECT SUM(json_extract(content, '$.cost')) AS cost FROM messages WHERE author = ? AND json_extract(content, '$.type') = 'gg-place'
`, [this.whoami]);
let spent = rows[0].cost;
this.currency = currency - spent;
this.status = {text: 'getting placed emojis'};
rows = await tfrpc.rpc.query(`
SELECT messages.content
FROM messages_fts('"gg-place"')
JOIN messages ON messages.rowid = messages_fts.rowid
WHERE json_extract(messages.content, '$.type') = 'gg-place'
ORDER BY messages.timestamp
`);
for (let row of rows) {
console.log(row.content);
let content = JSON.parse(row.content);
this.placed_emojis.push({
position: content.position,
emoji: content.emoji,
});
}
console.log(this.placed_emojis);
this.status = undefined;
this.update_map();
}
async sync_activities() {
let ids = this.activities.map(x => `https://www.strava.com/activities/${x.id}`);
let missing = await tfrpc.rpc.query(`
WITH my_activities AS (
SELECT json_extract(mention.value, '$.link') AS url
FROM messages, json_each(messages.content, '$.mentions') AS mention
WHERE
author = ? AND
json_extract(messages.content, '$.type') = 'gg-activity' AND
json_extract(mention.value, '$.name') = 'activity_url')
SELECT from_strava.value FROM json_each(?) AS from_strava
LEFT OUTER JOIN my_activities ON from_strava.value = my_activities.url
WHERE my_activities.url IS NULL
`, [this.whoami, JSON.stringify(ids)]);
console.log('missing = ', missing);
for (let [index, row] of missing.entries()) {
this.status = {text: 'syncing from strava', value: index, max: missing.length};
let url = row.value;
let id = url.match(/.*\/(\d+)/)[1];
let response = await fetch(`https://www.strava.com/api/v3/activities/${id}`, {
headers: {
'Authorization': `Bearer ${this.strava.access_token}`,
},
});
let activity = await response.json();
let blob_id = await tfrpc.rpc.store_blob(JSON.stringify(activity));
let message = {
type: 'gg-activity',
mentions: [
{
link: url,
name: 'activity_url',
},
{
link: blob_id,
name: 'activity_data',
}
],
};
await tfrpc.rpc.appendMessage(this.whoami, message);
}
this.status = undefined;
}
async acquire_ssb_identity() {
let user = await tfrpc.rpc.getUser();
if (!user?.credentials?.session?.name) {
return;
}
let ids = await tfrpc.rpc.getIdentities();
let players = ids.length ? (await tfrpc.rpc.query(`
SELECT author FROM messages JOIN json_each(?) ON messages.author = json_each.value
WHERE
json_extract(messages.content, '$.type') = 'gg-player' AND
json_extract(messages.content, '$.active')
ORDER BY timestamp DESC limit 1
`, [JSON.stringify(ids)])).map(row => row.author) : [];
if (!players.length) {
this.whoami = await tfrpc.rpc.createIdentity();
if (this.whoami) {
await tfrpc.rpc.appendMessage(this.whoami, {
type: 'gg-player',
active: true,
});
}
} else {
players.sort();
this.whoami = players[0];
}
}
async update_credentials() {
let name = this.user?.credentials?.session?.name;
if (!name) {
return;
}
let shared = await tfrpc.rpc.sharedDatabaseGet(name);
if (shared) {
await tfrpc.rpc.databaseSet('strava', shared);
await tfrpc.rpc.sharedDatabaseRemove(name);
}
this.strava = JSON.parse(await tfrpc.rpc.databaseGet('strava') || '{}');
if (new Date().valueOf() / 1000 > this.strava.expires_at) {
console.log('this looks expired', new Date().valueOf() / 1000, '>', this.strava.expires_at);
let x = await tfrpc.rpc.refresh_token(this.strava);
if (x) {
this.strava = x;
await tfrpc.rpc.databaseSet('strava', JSON.stringify(x));
} else {
this.strava = null;
}
}
}
async update_activities() {
if (this?.strava?.access_token) {
let response = await fetch('https://www.strava.com/api/v3/athlete/activities', {
headers: {
'Authorization': `Bearer ${this.strava.access_token}`,
},
});
this.activities = await response.json();
this.activities.sort((a, b) => (a.id - b.id));
}
}
color_to_emoji(color) {
const k_map = [
[k_color_snow, '⬜'],
[k_color_ice, '🟦'],
[k_color_water, '🟦'],
[k_color_dirt, '🟫'],
[k_color_pavement, '⬛'],
[k_color_grass, '🟩'],
[k_color_default, '🟧'],
];
for (let m of k_map) {
if (m[0][0] == color[0] &&
m[0][1] == color[1] &&
m[0][2] == color[2] &&
m[0][3] == color[3]) {
return m[1];
}
}
}
activity_bounds(activity) {
let min_lat = Number.MAX_VALUE;
let min_lon = Number.MAX_VALUE;
let max_lat = -Number.MAX_VALUE;
let max_lon = -Number.MAX_VALUE;
if (activity?.map?.polyline) {
for (let pt of polyline.decode(activity.map.polyline)) {
min_lat = Math.min(min_lat, pt[0]);
min_lon = Math.min(min_lon, pt[1]);
max_lat = Math.max(max_lat, pt[0]);
max_lon = Math.max(max_lon, pt[1]);
}
}
if (activity?.segments) {
for (let segment of activity.segments) {
for (let pt of segment) {
min_lat = Math.min(min_lat, pt.lat);
min_lon = Math.min(min_lon, pt.lon);
max_lat = Math.max(max_lat, pt.lat);
max_lon = Math.max(max_lon, pt.lon);
}
}
}
return {
min: {
lat: min_lat,
lng: min_lon,
},
max: {
lat: max_lat,
lng: max_lon,
},
};
}
on_click(event) {
let popup = L.popup()
.setLatLng(event.latlng)
.setContent(`
<div><a target="_top" href="https://www.google.com/maps/search/?api=1&query=${event.latlng.lat},${event.latlng.lng}">${event.latlng.lat}, ${event.latlng.lng}</a></div>
`)
.openOn(this.leaflet);
}
async build() {
if (this.popup) {
this.popup.remove();
}
if (!this.marker) {
return;
}
let latlng = this.marker.getLatLng();
let cost = k_store[this.to_build];
if (cost > this.currency) {
alert('Insufficient funds.');
return;
}
let message = {
type: 'gg-place',
position: {lat: latlng.lat, lng: latlng.lng},
emoji: this.to_build,
cost: cost,
};
let id = await tfrpc.rpc.appendMessage(this.whoami, message);
this.marker.remove();
this.placed_emojis.push({
position: {lat: latlng.lat, lng: latlng.lng},
emoji: this.to_build,
});
this.currency -= cost;
return this.update_map();
}
on_marker_click(event) {
this.popup = L.popup()
.setLatLng(event.latlng)
.setContent(`
${this.to_build} (-${k_store[this.to_build]}) <input type="button" value="Build" onclick="document.getElementById('ggapp').build()"></input>
`)
.openOn(this.leaflet);
}
snap_to_grid(latlng, fudge, zoom) {
let position = this.leaflet.options.crs.latLngToPoint(latlng, zoom ?? this.leaflet.getZoom());
position.x = Math.round(position.x / 16) * 16 + (fudge?.x ?? 0);
position.y = Math.round(position.y / 16) * 16 + (fudge?.y ?? 0);
position = this.leaflet.options.crs.pointToLatLng(position, zoom ?? this.leaflet.getZoom());
return position;
}
on_marker_move(event) {
if (!this.no_snap && this.marker) {
this.no_snap = true;
this.marker.setLatLng(this.snap_to_grid(this.marker.getLatLng(), k_marker_snap));
this.no_snap = false;
}
}
on_zoom(event) {
if (this.marker) {
this.marker.setLatLng(this.snap_to_grid(this.marker.getLatLng(), k_marker_snap));
}
}
on_mouse_down(event) {
if (this.marker) {
this.marker.remove();
this.marker = undefined;
}
if (this.to_build) {
this.marker = L.marker(this.snap_to_grid(event.latlng, k_marker_snap), {icon: L.divIcon({className: 'build-icon'}), draggable: true}).addTo(this.leaflet);
this.marker.on({click: this.on_marker_click.bind(this)});
this.marker.on({drag: this.on_marker_move.bind(this)});
}
}
async update_map() {
let map = this.shadowRoot.getElementById('map');
if (!map || !this.loaded_activities.length) {
this.leaflet = undefined;
this.grid_layer = undefined;
return;
}
if (!this.leaflet) {
this.leaflet = L.map(map, {attributionControl: false, maxZoom: 16, bounceAtZoomLimits: false});
this.leaflet.on({contextmenu: this.on_click.bind(this)});
this.leaflet.on({click: this.on_mouse_down.bind(this)});
this.leaflet.on({zoom: this.on_zoom.bind(this)});
}
let self = this;
let grid_layer = L.GridLayer.extend({
createTile: function(coords) {
var tile = L.DomUtil.create('canvas', 'leaflet-tile');
var size = this.getTileSize();
tile.width = size.x;
tile.height = size.y;
var context = tile.getContext('2d');
context.font = '10pt sans';
let bounds = this._tileCoordsToBounds(coords);
let degrees = 360.0 / (2 ** coords.z);
let ul = bounds.getNorthWest();
let lr = bounds.getSouthEast();
let mini = document.createElement('canvas');
mini.width = Math.floor(size.x / 16.0);
mini.height = Math.floor(size.y / 16.0);
let mini_context = mini.getContext('2d');
let image_data = context.getImageData(0, 0, mini.width, mini.height);
for (let activity of self.loaded_activities) {
self.draw_activity_to_tile(image_data, mini.width, mini.height, ul, lr, activity);
}
context.textAlign = 'left';
context.textBaseline = 'bottom';
for (let x = 0; x < mini.width; x++) {
for (let y = 0; y < mini.height; y++) {
let start = (y * mini.width + x) * 4;
let pixel = self.color_to_emoji(image_data.data.slice(start, start + 4));
if (pixel) {
//context.fillRect(x * size.x / mini.width, y * size.y / mini.height, size.x / mini.width, size.y / mini.height);
context.fillText(pixel, x * size.x / mini.width, y * size.y / mini.height + mini.height);
}
}
}
for (let placed of self.placed_emojis) {
let position = self.leaflet.options.crs.latLngToPoint(self.snap_to_grid(placed.position, undefined, coords.z), coords.z);
let tile_x = Math.floor(position.x / size.x);
let tile_y = Math.floor(position.y / size.y);
position.x = position.x - tile_x * size.x;
position.y = position.y - tile_y * size.y;
if (tile_x == coords.x && tile_y == coords.y) {
//context.fillRect(position.x, position.y, size.x / mini.width, size.y / mini.height);
context.fillText(placed.emoji, position.x, position.y + mini.height);
}
}
return tile;
}
});
if (this.grid_layer) {
this.grid_layer.redraw();
} else {
this.grid_layer = new grid_layer();
this.grid_layer.addTo(this.leaflet);
}
for (let activity of this.loaded_activities) {
let bounds = this.activity_bounds(activity);
this.min_lat = Math.min(this.min_lat, bounds.min.lat);
this.min_lon = Math.min(this.min_lon, bounds.min.lng);
this.max_lat = Math.max(this.max_lat, bounds.max.lat);
this.max_lon = Math.max(this.max_lon, bounds.max.lng);
}
if (this.focus) {
this.leaflet.fitBounds([
this.focus.min,
this.focus.max,
]);
this.focus = undefined;
} else {
this.leaflet.fitBounds([
[this.min_lat, this.min_lon],
[this.max_lat, this.max_lon],
]);
}
}
activity_to_color(activity) {
let color = [0, 0, 0, 255];
switch (activity.sport_type) {
/* Implies snow. */
case 'AlpineSki':
case 'BackcountrySki':
case 'NordicSki':
case 'Snowshoe':
case 'Snowboard':
color = k_color_snow;
break;
/* Implies ice. */
case 'IceSkate':
case 'InlineSkate':
color = k_color_ice;
break;
/* Implies water. */
case 'Canoeing':
case 'Kayaking':
case 'Kitesurf':
case 'Rowing':
case 'Sail':
case 'StandUpPaddling':
case 'Surfing':
case 'Swim':
case 'Windsurf':
color = k_color_water;
break;
/* Implies dirt. */
case 'EMountainBikeRide':
case 'Hike':
case 'MountainBikeRide':
case 'RockClimbing':
case 'TrailRun':
color = k_color_dirt;
break;
/* Implies pavement. */
case 'EBikeRide':
case 'GravelRide':
case 'Handcycle':
case 'Ride':
case 'RollerSki':
case 'Run':
case 'Skateboard':
case 'Badminton':
case 'Tennis':
case 'Velomobile':
case 'Walk':
case 'Wheelchair':
color = k_color_pavement;
break;
/* Grass, maybe? */
case 'Golf':
case 'Soccer':
case 'Squash':
color = k_color_grass;
break;
// Crossfit,
// Elliptical
// HighIntensityIntervalTraining
// Pickleball
// Pilates
// Racquetball
// StairStepper
// TableTennis,
// VirtualRide
// VirtualRow
// VirtualRun
// WeightTraining
// Workout
// Yoga
default:
color = k_color_default;
}
return color;
}
line(image_data, x0, y0, x1, y1, value) {
/* <3 https://en.wikipedia.org/wiki/Bresenham%27s_line_algorithm */
let dx = Math.abs(x1 - x0);
let sx = x0 < x1 ? 1 : -1;
let dy = -Math.abs(y1 - y0);
let sy = y0 < y1 ? 1 : -1;
let error = dx + dy;
while (true) {
if (x0 >= 0 && y0 >= 0 && x0 < image_data.width && y0 < image_data.height) {
let base = (y0 * image_data.width + x0) * 4;
image_data.data[base + 0] = value[0];
image_data.data[base + 1] = value[1];
image_data.data[base + 2] = value[2];
image_data.data[base + 3] = value[3];
}
if (x0 == x1 && y0 == y1) {
break;
}
let e2 = 2 * error;
if (e2 >= dy) {
if (x0 == x1) {
break;
}
error += dy;
x0 = Math.round(x0 + sx);
}
if (e2 <= dx) {
if (y0 == y1) {
break;
}
error += dx;
y0 = Math.round(y0 + sy);
}
}
}
draw_activity_to_tile(image_data, width, height, ul, lr, activity) {
let color = this.activity_to_color(activity);
if (activity?.map?.polyline) {
let last;
for (let pt of polyline.decode(activity.map.polyline)) {
let px = [
Math.floor(width * (pt[1] - ul.lng) / (lr.lng - ul.lng)),
Math.floor(height * (pt[0] - ul.lat) / (lr.lat - ul.lat)),
];
if (last) {
this.line(image_data, last[0], last[1], px[0], px[1], color);
}
last = px;
}
}
if (activity?.segments) {
for (let segment of activity.segments) {
let last;
for (let pt of segment) {
let px = [
Math.floor(width * (pt.lon - ul.lng) / (lr.lng - ul.lng)),
Math.floor(height * (pt.lat - ul.lat) / (lr.lat - ul.lat)),
];
if (last) {
this.line(image_data, last[0], last[1], px[0], px[1], color);
}
last = px;
}
}
}
}
async on_upload(event) {
try {
let file = event.srcElement.files[0];
let xml = await file.text();
let gpx = gpx_parse(xml);
let blob_id = await tfrpc.rpc.store_blob(xml);
console.log('blob_id = ', blob_id);
console.log(gpx);
let message = {
type: 'gg-activity',
mentions: [
{
link: `https://${gpx.link}/activity/${gpx.time}`,
name: 'activity_url',
},
{
link: blob_id,
name: 'activity_data',
}
],
};
console.log('id =', this.whoami, 'message = ', message);
let id = await tfrpc.rpc.appendMessage(this.whoami, message);
console.log('appended message', id);
alert('Activity uploaded.');
await this.get_activities_from_ssb();
} catch (e) {
alert(`Error: ${JSON.stringify(e, null, 2)}`);
}
}
upload() {
let input = document.createElement('input');
input.type = 'file';
input.onchange = (event) => this.on_upload(event);
input.click();
}
updated() {
this.update_map();
}
focus_map(activity) {
let bounds = this.activity_bounds(activity);
if (bounds.min.lat < bounds.max.lat &&
bounds.min.lng < bounds.max.lng) {
this.tab = 'map';
this.focus = bounds;
}
}
render_news() {
return html`
<ul>
${this.loaded_activities.map(x => html`
<li style="cursor: pointer" @click=${() => this.focus_map(x)}>${x.author} ${x.name ?? x.time}</li>
`)}
</ul>
`;
}
render_store_item(item) {
let [emoji, cost] = item;
return html`
<div>
<input type="button" value="${emoji}" @click=${() => this.to_build = emoji}></input> ${cost} ${emoji == this.to_build ? '<-- Will be built next' : undefined}
</div>
`;
}
render_store() {
let store = Object.assign({}, k_store);
store[this.emoji_of_the_day] = 5;
return html`
<h2>Store</h2>
<div><b>Your balance:</b> ${this.currency}</div>
${Object.entries(store).map(this.render_store_item.bind(this))}
`;
}
render() {
let header;
if (!this.user?.credentials?.session?.name) {
header = html`<div style="flex: 1 0">Please <a target="_top" href="/login?return=${this.url}">login</a> to Tilde Friends, first.</div>`;
} else if (!this.strava?.access_token) {
let strava_url = `https://www.strava.com/oauth/authorize?client_id=${k_client_id}&redirect_uri=${k_redirect_url}&response_type=code&approval_prompt=auto&scope=activity%3Aread&state=${g_data.state}`;
header = html`
<div style="flex: 1 0; display: flex; flex-direction: row; align-items: center; gap: 1em; width: 100%">
<div style="flex: 1 1">Please <a target="_top" href=${strava_url}>login</a> to Strava.</div>
<span style="font-size: xx-small; flex: 1 1; word-break: break-all">${this.whoami}</span>
<input type="button" value="📁" @click=${this.upload}></input>
</div>
`;
} else {
header = html`
<div>
<div style="flex: 1 0; display: flex; flex-direction: row; align-items: center; gap: 1em; width: 100%">
<h1>Welcome, ${this.user.credentials.session.name}</h1>
<span style="font-size: xx-small; flex: 1 1; word-break: break-all">${this.whoami}</span>
<input type="button" value="📁" @click=${this.upload}></input>
</div>
<h3 ?hidden=${!this.status?.text}>${this.status?.text} <progress ?hidden=${!this.status?.max} value=${this.status?.value} max=${this.status?.max}>${this.status?.value}</progress></h3>
</div>
`;
}
let navigation = html`
<style>
#navigation input[type="button"] {
min-width: 3em;
min-height: 3em;
flex: 1 0;
font-size: large;
}
</style>
<div id="navigation" style="display: flex; flex-direction: row">
<input type="button" id="button_map" @click=${() => this.tab = 'map'} value="🗺Map"></input>
<input type="button" id="button_news" @click=${() => this.tab = 'news'} value="🏃News"></input>
<input type="button" id="button_friends" @click=${() => this.tab = 'friends'} value="👫Friends"></input>
<input type="button" id="button_store" @click=${() => this.tab = 'store'} value="🏗Store"></input>
</div>
`;
let content;
switch (this.tab) {
case 'map':
content = html`<div id="map" style="width: 100%; height: 100%"></div>`;
break;
case 'news':
content = this.render_news();
break;
case 'friends':
content = html`<div>Friends</div>`;
break;
case 'store':
content = this.render_store();
break;
}
return html`
<style>
.build-icon::before {
content: '📍';
border: 2px solid red;
}
</style>
<link rel="stylesheet" href="leaflet.css"/>
<div style="width: 100%; height: 100%; display: flex; flex-direction: column">
${header}
<div style="flex: 1 0; overflow: scroll">${content}</div>
${navigation}
</div>
`;
}
}
customElements.define('gg-app', GgAppElement);

20
apps/gg/strava.js Normal file
View File

@ -0,0 +1,20 @@
const k_client_id = '28276';
const k_client_secret = '3123f1f5afe132d9731111066d1d17bdb22ef27e';
const k_access_token = 'f753e77764c26252bd2d80e7c5cc17ace51a8864';
const k_refresh_token = 'f58d8e1b5a3ec3bf96e681589d5014f9a294f5a4';
const k_redirect_url = 'https://tildefriends.net/~cory/gg/login';
export async function refresh_token(token) {
let r = await fetch('https://www.strava.com/api/v3/oauth/token', {
method: 'POST',
body: `client_id=${k_client_id}&client_secret=${k_client_secret}&refresh_token=${token.refresh_token}&grant_type=refresh_token`,
});
return r?.body ? JSON.parse(utf8Decode(r.body)) : undefined;
}
export async function authorization_code(code) {
return await fetch('https://www.strava.com/api/v3/oauth/token', {
method: 'POST',
body: `client_id=${k_client_id}&client_secret=${k_client_secret}&code=${code}&grant_type=authorization_code`,
});
}

View File

@ -1,5 +1,5 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "🪪", "emoji": "🪪",
"previous": "&5kw/2PgcySwOYCmAkjHTR2xTkIx3i7UjQmtQ8MfgWw8=.sha256" "previous": "&kgukkyDk1RxgfzgMH6H/0QeDPIuwPZypLuAFax21ljk=.sha256"
} }

View File

@ -1,7 +1,5 @@
import * as tfrpc from '/tfrpc.js'; import * as tfrpc from '/tfrpc.js';
const is_admin = core.user?.credentials?.permissions?.administration;
tfrpc.register(async function get_private_key(id) { tfrpc.register(async function get_private_key(id) {
return bip39Words(await ssb.getPrivateKey(id)); return bip39Words(await ssb.getPrivateKey(id));
}); });
@ -17,44 +15,10 @@ tfrpc.register(async function delete_id(id) {
tfrpc.register(async function reload() { tfrpc.register(async function reload() {
await main(); await main();
}); });
tfrpc.register(async function make_server(id) {
return await ssb.swapWithServerIdentity(id);
});
async function main() { async function main() {
let ids = await ssb.getIdentities(); let ids = await ssb.getIdentities();
let server_id = await ssb.getServerIdentity(); await app.setDocument(`<body style="color: #fff">
await app.setDocument(
`
<head>
<link rel="stylesheet" href="w3.css"></link>
<style>
/* "2018 Sargasso Sea" */
.w3-theme-l5 {color:#000 !important; background-color:#f3f4f7 !important}
.w3-theme-l4 {color:#000 !important; background-color:#d7dbe3 !important}
.w3-theme-l3 {color:#000 !important; background-color:#b0b6c8 !important}
.w3-theme-l2 {color:#fff !important; background-color:#8892ac !important}
.w3-theme-l1 {color:#fff !important; background-color:#636f8e !important}
.w3-theme-d1 {color:#fff !important; background-color:#40485c !important}
.w3-theme-d2 {color:#fff !important; background-color:#394052 !important}
.w3-theme-d3 {color:#fff !important; background-color:#323848 !important}
.w3-theme-d4 {color:#fff !important; background-color:#2b303d !important}
.w3-theme-d5 {color:#fff !important; background-color:#242833 !important}
.w3-theme-light {color:#000 !important; background-color:#f3f4f7 !important}
.w3-theme-dark {color:#fff !important; background-color:#242833 !important}
.w3-theme-action {color:#fff !important; background-color:#242833 !important}
.w3-theme {color:#fff !important; background-color:#485167 !important}
.w3-text-theme {color:#485167 !important}
.w3-border-theme {border-color:#485167 !important}
.w3-hover-theme:hover {color:#fff !important; background-color:#485167 !important}
.w3-hover-text-theme:hover {color:#485167 !important}
.w3-hover-border-theme:hover {border-color:#485167 !important}
</style>
</head>
<body class="w3-theme-l3">
<script>const handler = {};</script> <script>const handler = {};</script>
<script type="module"> <script type="module">
import * as tfrpc from '/static/tfrpc.js'; import * as tfrpc from '/static/tfrpc.js';
@ -62,8 +26,7 @@ async function main() {
let id = event.srcElement.dataset.id; let id = event.srcElement.dataset.id;
let element = document.createElement('textarea'); let element = document.createElement('textarea');
element.value = await tfrpc.rpc.get_private_key(id); element.value = await tfrpc.rpc.get_private_key(id);
element.style = 'width: 100%; height: auto; read-only: true; resize: none'; element.style = 'width: 100%; read-only: true';
element.classList.add('w3-input');
element.readOnly = true; element.readOnly = true;
event.srcElement.parentElement.appendChild(element); event.srcElement.parentElement.appendChild(element);
event.srcElement.onclick = event => handler.hide_id(event, element); event.srcElement.onclick = event => handler.hide_id(event, element);
@ -84,7 +47,7 @@ async function main() {
alert('Successfully created: ' + id); alert('Successfully created: ' + id);
await tfrpc.rpc.reload(); await tfrpc.rpc.reload();
} catch (e) { } catch (e) {
alert('Error creating identity: ' + e.message); alert('Error creating identity: ' + e);
} }
} }
handler.hide_id = function hide_id(event, element) { handler.hide_id = function hide_id(event, element) {
@ -104,50 +67,21 @@ async function main() {
alert('Error deleting ID: ' + e); alert('Error deleting ID: ' + e);
} }
} }
handler.make_server = async function make_server(event) {
let id = event.srcElement.dataset.id;
try {
if (confirm('Are you sure you want to make "' + id + '" the server identity?\\n\\nFor it to take effect, you will need to both sign in again and restart Tilde Friends.')) {
await tfrpc.rpc.make_server(id);
}
} catch (e) {
alert('Error making server ID: ' + e);
}
}
</script> </script>
<header class="w3-theme w3-padding"><h1>SSB Identity Management</h1></header> <h1>SSB Identity Management</h1>
<div class="w3-card-4 w3-margin"> <h2>Create a new identity</h2>
<header class="w3-container w3-theme-l2"><h2>Create a new identity</h2></header> <button id="create_id" onclick="handler.create_id()">Create Identity</button>
<footer class="w3-padding"> <h2>Import an SSB Identity from 12 BIP39 English Words</h2>
<button id="create_id" onclick="handler.create_id()" class="w3-button w3-theme">Create Identity</button> <textarea id="add_id" style="width: 100%" rows="4"></textarea><button id="add" onclick="handler.add_id(event)">Import Identity</button>
</footer> <h2>Identities</h2>
</div> <ul>`+
<div class="w3-card-4 w3-margin"> ids.map(id => `<li>
<header class="w3-container w3-theme-l2"><h2>Import an SSB Identity from 12 BIP39 English Words</h2></header> <button onclick="handler.export_id(event)" data-id="${id}">Export Identity</button>
<textarea id="add_id" style="width: 100%" rows="4" class="w3-input"></textarea> <button onclick="handler.delete_id(event)" data-id="${id}">Delete Identity</button>
<footer class="w3-padding"> ${id}
<button id="add" onclick="handler.add_id(event)" class="w3-button w3-theme">Import Identity</button> </li>`).join('\n')+
</footer> ` </ul>
</div> </body>`);
<div class="w3-card-4 w3-margin">
<header class="w3-container w3-theme-l2"><h2>Identities</h2></header>
<ul class="w3-ul">` +
(ids ?? [])
.map(
(
id
) => `<li style="overflow: hidden; text-wrap: nowrap; text-overflow: ellipsis">
<button onclick="handler.export_id(event)" data-id="${id}" class="w3-button w3-theme">Export Identity</button>
<button onclick="handler.delete_id(event)" data-id="${id}" class="w3-button w3-theme">Delete Identity</button>
${is_admin && id != server_id ? `<button onclick="handler.make_server(event)" data-id="${id}" class="w3-button w3-theme">Make Server Identity</button>` : ''}
${id}${id == server_id ? ' <div class="w3-tag w3-theme-l4 w3-round">🖥 local server</div>' : ''}
</li>`
)
.join('\n') +
` </ul>
</div>
</body>`
);
} }
main(); main();

View File

@ -1,235 +0,0 @@
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
/* Extract from normalize.css by Nicolas Gallagher and Jonathan Neal git.io/normalize */
html{-ms-text-size-adjust:100%;-webkit-text-size-adjust:100%}body{margin:0}
article,aside,details,figcaption,figure,footer,header,main,menu,nav,section{display:block}summary{display:list-item}
audio,canvas,progress,video{display:inline-block}progress{vertical-align:baseline}
audio:not([controls]){display:none;height:0}[hidden],template{display:none}
a{background-color:transparent}a:active,a:hover{outline-width:0}
abbr[title]{border-bottom:none;text-decoration:underline;text-decoration:underline dotted}
b,strong{font-weight:bolder}dfn{font-style:italic}mark{background:#ff0;color:#000}
small{font-size:80%}sub,sup{font-size:75%;line-height:0;position:relative;vertical-align:baseline}
sub{bottom:-0.25em}sup{top:-0.5em}figure{margin:1em 40px}img{border-style:none}
code,kbd,pre,samp{font-family:monospace,monospace;font-size:1em}hr{box-sizing:content-box;height:0;overflow:visible}
button,input,select,textarea,optgroup{font:inherit;margin:0}optgroup{font-weight:bold}
button,input{overflow:visible}button,select{text-transform:none}
button,[type=button],[type=reset],[type=submit]{-webkit-appearance:button}
button::-moz-focus-inner,[type=button]::-moz-focus-inner,[type=reset]::-moz-focus-inner,[type=submit]::-moz-focus-inner{border-style:none;padding:0}
button:-moz-focusring,[type=button]:-moz-focusring,[type=reset]:-moz-focusring,[type=submit]:-moz-focusring{outline:1px dotted ButtonText}
fieldset{border:1px solid #c0c0c0;margin:0 2px;padding:.35em .625em .75em}
legend{color:inherit;display:table;max-width:100%;padding:0;white-space:normal}textarea{overflow:auto}
[type=checkbox],[type=radio]{padding:0}
[type=number]::-webkit-inner-spin-button,[type=number]::-webkit-outer-spin-button{height:auto}
[type=search]{-webkit-appearance:textfield;outline-offset:-2px}
[type=search]::-webkit-search-decoration{-webkit-appearance:none}
::-webkit-file-upload-button{-webkit-appearance:button;font:inherit}
/* End extract */
html,body{font-family:Verdana,sans-serif;font-size:15px;line-height:1.5}html{overflow-x:hidden}
h1{font-size:36px}h2{font-size:30px}h3{font-size:24px}h4{font-size:20px}h5{font-size:18px}h6{font-size:16px}
.w3-serif{font-family:serif}.w3-sans-serif{font-family:sans-serif}.w3-cursive{font-family:cursive}.w3-monospace{font-family:monospace}
h1,h2,h3,h4,h5,h6{font-family:"Segoe UI",Arial,sans-serif;font-weight:400;margin:10px 0}.w3-wide{letter-spacing:4px}
hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-image{max-width:100%;height:auto}img{vertical-align:middle}a{color:inherit}
.w3-table,.w3-table-all{border-collapse:collapse;border-spacing:0;width:100%;display:table}.w3-table-all{border:1px solid #ccc}
.w3-bordered tr,.w3-table-all tr{border-bottom:1px solid #ddd}.w3-striped tbody tr:nth-child(even){background-color:#f1f1f1}
.w3-table-all tr:nth-child(odd){background-color:#fff}.w3-table-all tr:nth-child(even){background-color:#f1f1f1}
.w3-hoverable tbody tr:hover,.w3-ul.w3-hoverable li:hover{background-color:#ccc}.w3-centered tr th,.w3-centered tr td{text-align:center}
.w3-table td,.w3-table th,.w3-table-all td,.w3-table-all th{padding:8px 8px;display:table-cell;text-align:left;vertical-align:top}
.w3-table th:first-child,.w3-table td:first-child,.w3-table-all th:first-child,.w3-table-all td:first-child{padding-left:16px}
.w3-btn,.w3-button{border:none;display:inline-block;padding:8px 16px;vertical-align:middle;overflow:hidden;text-decoration:none;color:inherit;background-color:inherit;text-align:center;cursor:pointer;white-space:nowrap}
.w3-btn:hover{box-shadow:0 8px 16px 0 rgba(0,0,0,0.2),0 6px 20px 0 rgba(0,0,0,0.19)}
.w3-btn,.w3-button{-webkit-touch-callout:none;-webkit-user-select:none;-khtml-user-select:none;-moz-user-select:none;-ms-user-select:none;user-select:none}
.w3-disabled,.w3-btn:disabled,.w3-button:disabled{cursor:not-allowed;opacity:0.3}.w3-disabled *,:disabled *{pointer-events:none}
.w3-btn.w3-disabled:hover,.w3-btn:disabled:hover{box-shadow:none}
.w3-badge,.w3-tag{background-color:#000;color:#fff;display:inline-block;padding-left:8px;padding-right:8px;text-align:center}.w3-badge{border-radius:50%}
.w3-ul{list-style-type:none;padding:0;margin:0}.w3-ul li{padding:8px 16px;border-bottom:1px solid #ddd}.w3-ul li:last-child{border-bottom:none}
.w3-tooltip,.w3-display-container{position:relative}.w3-tooltip .w3-text{display:none}.w3-tooltip:hover .w3-text{display:inline-block}
.w3-ripple:active{opacity:0.5}.w3-ripple{transition:opacity 0s}
.w3-input{padding:8px;display:block;border:none;border-bottom:1px solid #ccc;width:100%}
.w3-select{padding:9px 0;width:100%;border:none;border-bottom:1px solid #ccc}
.w3-dropdown-click,.w3-dropdown-hover{position:relative;display:inline-block;cursor:pointer}
.w3-dropdown-hover:hover .w3-dropdown-content{display:block}
.w3-dropdown-hover:first-child,.w3-dropdown-click:hover{background-color:#ccc;color:#000}
.w3-dropdown-hover:hover > .w3-button:first-child,.w3-dropdown-click:hover > .w3-button:first-child{background-color:#ccc;color:#000}
.w3-dropdown-content{cursor:auto;color:#000;background-color:#fff;display:none;position:absolute;min-width:160px;margin:0;padding:0;z-index:1}
.w3-check,.w3-radio{width:24px;height:24px;position:relative;top:6px}
.w3-sidebar{height:100%;width:200px;background-color:#fff;position:fixed!important;z-index:1;overflow:auto}
.w3-bar-block .w3-dropdown-hover,.w3-bar-block .w3-dropdown-click{width:100%}
.w3-bar-block .w3-dropdown-hover .w3-dropdown-content,.w3-bar-block .w3-dropdown-click .w3-dropdown-content{min-width:100%}
.w3-bar-block .w3-dropdown-hover .w3-button,.w3-bar-block .w3-dropdown-click .w3-button{width:100%;text-align:left;padding:8px 16px}
.w3-main,#main{transition:margin-left .4s}
.w3-modal{z-index:3;display:none;padding-top:100px;position:fixed;left:0;top:0;width:100%;height:100%;overflow:auto;background-color:rgb(0,0,0);background-color:rgba(0,0,0,0.4)}
.w3-modal-content{margin:auto;background-color:#fff;position:relative;padding:0;outline:0;width:600px}
.w3-bar{width:100%;overflow:hidden}.w3-center .w3-bar{display:inline-block;width:auto}
.w3-bar .w3-bar-item{padding:8px 16px;float:left;width:auto;border:none;display:block;outline:0}
.w3-bar .w3-dropdown-hover,.w3-bar .w3-dropdown-click{position:static;float:left}
.w3-bar .w3-button{white-space:normal}
.w3-bar-block .w3-bar-item{width:100%;display:block;padding:8px 16px;text-align:left;border:none;white-space:normal;float:none;outline:0}
.w3-bar-block.w3-center .w3-bar-item{text-align:center}.w3-block{display:block;width:100%}
.w3-responsive{display:block;overflow-x:auto}
.w3-container:after,.w3-container:before,.w3-panel:after,.w3-panel:before,.w3-row:after,.w3-row:before,.w3-row-padding:after,.w3-row-padding:before,
.w3-cell-row:before,.w3-cell-row:after,.w3-clear:after,.w3-clear:before,.w3-bar:before,.w3-bar:after{content:"";display:table;clear:both}
.w3-col,.w3-half,.w3-third,.w3-twothird,.w3-threequarter,.w3-quarter{float:left;width:100%}
.w3-col.s1{width:8.33333%}.w3-col.s2{width:16.66666%}.w3-col.s3{width:24.99999%}.w3-col.s4{width:33.33333%}
.w3-col.s5{width:41.66666%}.w3-col.s6{width:49.99999%}.w3-col.s7{width:58.33333%}.w3-col.s8{width:66.66666%}
.w3-col.s9{width:74.99999%}.w3-col.s10{width:83.33333%}.w3-col.s11{width:91.66666%}.w3-col.s12{width:99.99999%}
@media (min-width:601px){.w3-col.m1{width:8.33333%}.w3-col.m2{width:16.66666%}.w3-col.m3,.w3-quarter{width:24.99999%}.w3-col.m4,.w3-third{width:33.33333%}
.w3-col.m5{width:41.66666%}.w3-col.m6,.w3-half{width:49.99999%}.w3-col.m7{width:58.33333%}.w3-col.m8,.w3-twothird{width:66.66666%}
.w3-col.m9,.w3-threequarter{width:74.99999%}.w3-col.m10{width:83.33333%}.w3-col.m11{width:91.66666%}.w3-col.m12{width:99.99999%}}
@media (min-width:993px){.w3-col.l1{width:8.33333%}.w3-col.l2{width:16.66666%}.w3-col.l3{width:24.99999%}.w3-col.l4{width:33.33333%}
.w3-col.l5{width:41.66666%}.w3-col.l6{width:49.99999%}.w3-col.l7{width:58.33333%}.w3-col.l8{width:66.66666%}
.w3-col.l9{width:74.99999%}.w3-col.l10{width:83.33333%}.w3-col.l11{width:91.66666%}.w3-col.l12{width:99.99999%}}
.w3-rest{overflow:hidden}.w3-stretch{margin-left:-16px;margin-right:-16px}
.w3-content,.w3-auto{margin-left:auto;margin-right:auto}.w3-content{max-width:980px}.w3-auto{max-width:1140px}
.w3-cell-row{display:table;width:100%}.w3-cell{display:table-cell}
.w3-cell-top{vertical-align:top}.w3-cell-middle{vertical-align:middle}.w3-cell-bottom{vertical-align:bottom}
.w3-hide{display:none!important}.w3-show-block,.w3-show{display:block!important}.w3-show-inline-block{display:inline-block!important}
@media (max-width:1205px){.w3-auto{max-width:95%}}
@media (max-width:600px){.w3-modal-content{margin:0 10px;width:auto!important}.w3-modal{padding-top:30px}
.w3-dropdown-hover.w3-mobile .w3-dropdown-content,.w3-dropdown-click.w3-mobile .w3-dropdown-content{position:relative}
.w3-hide-small{display:none!important}.w3-mobile{display:block;width:100%!important}.w3-bar-item.w3-mobile,.w3-dropdown-hover.w3-mobile,.w3-dropdown-click.w3-mobile{text-align:center}
.w3-dropdown-hover.w3-mobile,.w3-dropdown-hover.w3-mobile .w3-btn,.w3-dropdown-hover.w3-mobile .w3-button,.w3-dropdown-click.w3-mobile,.w3-dropdown-click.w3-mobile .w3-btn,.w3-dropdown-click.w3-mobile .w3-button{width:100%}}
@media (max-width:768px){.w3-modal-content{width:500px}.w3-modal{padding-top:50px}}
@media (min-width:993px){.w3-modal-content{width:900px}.w3-hide-large{display:none!important}.w3-sidebar.w3-collapse{display:block!important}}
@media (max-width:992px) and (min-width:601px){.w3-hide-medium{display:none!important}}
@media (max-width:992px){.w3-sidebar.w3-collapse{display:none}.w3-main{margin-left:0!important;margin-right:0!important}.w3-auto{max-width:100%}}
.w3-top,.w3-bottom{position:fixed;width:100%;z-index:1}.w3-top{top:0}.w3-bottom{bottom:0}
.w3-overlay{position:fixed;display:none;width:100%;height:100%;top:0;left:0;right:0;bottom:0;background-color:rgba(0,0,0,0.5);z-index:2}
.w3-display-topleft{position:absolute;left:0;top:0}.w3-display-topright{position:absolute;right:0;top:0}
.w3-display-bottomleft{position:absolute;left:0;bottom:0}.w3-display-bottomright{position:absolute;right:0;bottom:0}
.w3-display-middle{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%);-ms-transform:translate(-50%,-50%)}
.w3-display-left{position:absolute;top:50%;left:0%;transform:translate(0%,-50%);-ms-transform:translate(-0%,-50%)}
.w3-display-right{position:absolute;top:50%;right:0%;transform:translate(0%,-50%);-ms-transform:translate(0%,-50%)}
.w3-display-topmiddle{position:absolute;left:50%;top:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
.w3-display-bottommiddle{position:absolute;left:50%;bottom:0;transform:translate(-50%,0%);-ms-transform:translate(-50%,0%)}
.w3-display-container:hover .w3-display-hover{display:block}.w3-display-container:hover span.w3-display-hover{display:inline-block}.w3-display-hover{display:none}
.w3-display-position{position:absolute}
.w3-circle{border-radius:50%}
.w3-round-small{border-radius:2px}.w3-round,.w3-round-medium{border-radius:4px}.w3-round-large{border-radius:8px}.w3-round-xlarge{border-radius:16px}.w3-round-xxlarge{border-radius:32px}
.w3-row-padding,.w3-row-padding>.w3-half,.w3-row-padding>.w3-third,.w3-row-padding>.w3-twothird,.w3-row-padding>.w3-threequarter,.w3-row-padding>.w3-quarter,.w3-row-padding>.w3-col{padding:0 8px}
.w3-container,.w3-panel{padding:0.01em 16px}.w3-panel{margin-top:16px;margin-bottom:16px}
.w3-code,.w3-codespan{font-family:Consolas,"courier new";font-size:16px}
.w3-code{width:auto;background-color:#fff;padding:8px 12px;border-left:4px solid #4CAF50;word-wrap:break-word}
.w3-codespan{color:crimson;background-color:#f1f1f1;padding-left:4px;padding-right:4px;font-size:110%}
.w3-card,.w3-card-2{box-shadow:0 2px 5px 0 rgba(0,0,0,0.16),0 2px 10px 0 rgba(0,0,0,0.12)}
.w3-card-4,.w3-hover-shadow:hover{box-shadow:0 4px 10px 0 rgba(0,0,0,0.2),0 4px 20px 0 rgba(0,0,0,0.19)}
.w3-spin{animation:w3-spin 2s infinite linear}@keyframes w3-spin{0%{transform:rotate(0deg)}100%{transform:rotate(359deg)}}
.w3-animate-fading{animation:fading 10s infinite}@keyframes fading{0%{opacity:0}50%{opacity:1}100%{opacity:0}}
.w3-animate-opacity{animation:opac 0.8s}@keyframes opac{from{opacity:0} to{opacity:1}}
.w3-animate-top{position:relative;animation:animatetop 0.4s}@keyframes animatetop{from{top:-300px;opacity:0} to{top:0;opacity:1}}
.w3-animate-left{position:relative;animation:animateleft 0.4s}@keyframes animateleft{from{left:-300px;opacity:0} to{left:0;opacity:1}}
.w3-animate-right{position:relative;animation:animateright 0.4s}@keyframes animateright{from{right:-300px;opacity:0} to{right:0;opacity:1}}
.w3-animate-bottom{position:relative;animation:animatebottom 0.4s}@keyframes animatebottom{from{bottom:-300px;opacity:0} to{bottom:0;opacity:1}}
.w3-animate-zoom {animation:animatezoom 0.6s}@keyframes animatezoom{from{transform:scale(0)} to{transform:scale(1)}}
.w3-animate-input{transition:width 0.4s ease-in-out}.w3-animate-input:focus{width:100%!important}
.w3-opacity,.w3-hover-opacity:hover{opacity:0.60}.w3-opacity-off,.w3-hover-opacity-off:hover{opacity:1}
.w3-opacity-max{opacity:0.25}.w3-opacity-min{opacity:0.75}
.w3-greyscale-max,.w3-grayscale-max,.w3-hover-greyscale:hover,.w3-hover-grayscale:hover{filter:grayscale(100%)}
.w3-greyscale,.w3-grayscale{filter:grayscale(75%)}.w3-greyscale-min,.w3-grayscale-min{filter:grayscale(50%)}
.w3-sepia{filter:sepia(75%)}.w3-sepia-max,.w3-hover-sepia:hover{filter:sepia(100%)}.w3-sepia-min{filter:sepia(50%)}
.w3-tiny{font-size:10px!important}.w3-small{font-size:12px!important}.w3-medium{font-size:15px!important}.w3-large{font-size:18px!important}
.w3-xlarge{font-size:24px!important}.w3-xxlarge{font-size:36px!important}.w3-xxxlarge{font-size:48px!important}.w3-jumbo{font-size:64px!important}
.w3-left-align{text-align:left!important}.w3-right-align{text-align:right!important}.w3-justify{text-align:justify!important}.w3-center{text-align:center!important}
.w3-border-0{border:0!important}.w3-border{border:1px solid #ccc!important}
.w3-border-top{border-top:1px solid #ccc!important}.w3-border-bottom{border-bottom:1px solid #ccc!important}
.w3-border-left{border-left:1px solid #ccc!important}.w3-border-right{border-right:1px solid #ccc!important}
.w3-topbar{border-top:6px solid #ccc!important}.w3-bottombar{border-bottom:6px solid #ccc!important}
.w3-leftbar{border-left:6px solid #ccc!important}.w3-rightbar{border-right:6px solid #ccc!important}
.w3-section,.w3-code{margin-top:16px!important;margin-bottom:16px!important}
.w3-margin{margin:16px!important}.w3-margin-top{margin-top:16px!important}.w3-margin-bottom{margin-bottom:16px!important}
.w3-margin-left{margin-left:16px!important}.w3-margin-right{margin-right:16px!important}
.w3-padding-small{padding:4px 8px!important}.w3-padding{padding:8px 16px!important}.w3-padding-large{padding:12px 24px!important}
.w3-padding-16{padding-top:16px!important;padding-bottom:16px!important}.w3-padding-24{padding-top:24px!important;padding-bottom:24px!important}
.w3-padding-32{padding-top:32px!important;padding-bottom:32px!important}.w3-padding-48{padding-top:48px!important;padding-bottom:48px!important}
.w3-padding-64{padding-top:64px!important;padding-bottom:64px!important}
.w3-padding-top-64{padding-top:64px!important}.w3-padding-top-48{padding-top:48px!important}
.w3-padding-top-32{padding-top:32px!important}.w3-padding-top-24{padding-top:24px!important}
.w3-left{float:left!important}.w3-right{float:right!important}
.w3-button:hover{color:#000!important;background-color:#ccc!important}
.w3-transparent,.w3-hover-none:hover{background-color:transparent!important}
.w3-hover-none:hover{box-shadow:none!important}
/* Colors */
.w3-amber,.w3-hover-amber:hover{color:#000!important;background-color:#ffc107!important}
.w3-aqua,.w3-hover-aqua:hover{color:#000!important;background-color:#00ffff!important}
.w3-blue,.w3-hover-blue:hover{color:#fff!important;background-color:#2196F3!important}
.w3-light-blue,.w3-hover-light-blue:hover{color:#000!important;background-color:#87CEEB!important}
.w3-brown,.w3-hover-brown:hover{color:#fff!important;background-color:#795548!important}
.w3-cyan,.w3-hover-cyan:hover{color:#000!important;background-color:#00bcd4!important}
.w3-blue-grey,.w3-hover-blue-grey:hover,.w3-blue-gray,.w3-hover-blue-gray:hover{color:#fff!important;background-color:#607d8b!important}
.w3-green,.w3-hover-green:hover{color:#fff!important;background-color:#4CAF50!important}
.w3-light-green,.w3-hover-light-green:hover{color:#000!important;background-color:#8bc34a!important}
.w3-indigo,.w3-hover-indigo:hover{color:#fff!important;background-color:#3f51b5!important}
.w3-khaki,.w3-hover-khaki:hover{color:#000!important;background-color:#f0e68c!important}
.w3-lime,.w3-hover-lime:hover{color:#000!important;background-color:#cddc39!important}
.w3-orange,.w3-hover-orange:hover{color:#000!important;background-color:#ff9800!important}
.w3-deep-orange,.w3-hover-deep-orange:hover{color:#fff!important;background-color:#ff5722!important}
.w3-pink,.w3-hover-pink:hover{color:#fff!important;background-color:#e91e63!important}
.w3-purple,.w3-hover-purple:hover{color:#fff!important;background-color:#9c27b0!important}
.w3-deep-purple,.w3-hover-deep-purple:hover{color:#fff!important;background-color:#673ab7!important}
.w3-red,.w3-hover-red:hover{color:#fff!important;background-color:#f44336!important}
.w3-sand,.w3-hover-sand:hover{color:#000!important;background-color:#fdf5e6!important}
.w3-teal,.w3-hover-teal:hover{color:#fff!important;background-color:#009688!important}
.w3-yellow,.w3-hover-yellow:hover{color:#000!important;background-color:#ffeb3b!important}
.w3-white,.w3-hover-white:hover{color:#000!important;background-color:#fff!important}
.w3-black,.w3-hover-black:hover{color:#fff!important;background-color:#000!important}
.w3-grey,.w3-hover-grey:hover,.w3-gray,.w3-hover-gray:hover{color:#000!important;background-color:#9e9e9e!important}
.w3-light-grey,.w3-hover-light-grey:hover,.w3-light-gray,.w3-hover-light-gray:hover{color:#000!important;background-color:#f1f1f1!important}
.w3-dark-grey,.w3-hover-dark-grey:hover,.w3-dark-gray,.w3-hover-dark-gray:hover{color:#fff!important;background-color:#616161!important}
.w3-pale-red,.w3-hover-pale-red:hover{color:#000!important;background-color:#ffdddd!important}
.w3-pale-green,.w3-hover-pale-green:hover{color:#000!important;background-color:#ddffdd!important}
.w3-pale-yellow,.w3-hover-pale-yellow:hover{color:#000!important;background-color:#ffffcc!important}
.w3-pale-blue,.w3-hover-pale-blue:hover{color:#000!important;background-color:#ddffff!important}
.w3-text-amber,.w3-hover-text-amber:hover{color:#ffc107!important}
.w3-text-aqua,.w3-hover-text-aqua:hover{color:#00ffff!important}
.w3-text-blue,.w3-hover-text-blue:hover{color:#2196F3!important}
.w3-text-light-blue,.w3-hover-text-light-blue:hover{color:#87CEEB!important}
.w3-text-brown,.w3-hover-text-brown:hover{color:#795548!important}
.w3-text-cyan,.w3-hover-text-cyan:hover{color:#00bcd4!important}
.w3-text-blue-grey,.w3-hover-text-blue-grey:hover,.w3-text-blue-gray,.w3-hover-text-blue-gray:hover{color:#607d8b!important}
.w3-text-green,.w3-hover-text-green:hover{color:#4CAF50!important}
.w3-text-light-green,.w3-hover-text-light-green:hover{color:#8bc34a!important}
.w3-text-indigo,.w3-hover-text-indigo:hover{color:#3f51b5!important}
.w3-text-khaki,.w3-hover-text-khaki:hover{color:#b4aa50!important}
.w3-text-lime,.w3-hover-text-lime:hover{color:#cddc39!important}
.w3-text-orange,.w3-hover-text-orange:hover{color:#ff9800!important}
.w3-text-deep-orange,.w3-hover-text-deep-orange:hover{color:#ff5722!important}
.w3-text-pink,.w3-hover-text-pink:hover{color:#e91e63!important}
.w3-text-purple,.w3-hover-text-purple:hover{color:#9c27b0!important}
.w3-text-deep-purple,.w3-hover-text-deep-purple:hover{color:#673ab7!important}
.w3-text-red,.w3-hover-text-red:hover{color:#f44336!important}
.w3-text-sand,.w3-hover-text-sand:hover{color:#fdf5e6!important}
.w3-text-teal,.w3-hover-text-teal:hover{color:#009688!important}
.w3-text-yellow,.w3-hover-text-yellow:hover{color:#d2be0e!important}
.w3-text-white,.w3-hover-text-white:hover{color:#fff!important}
.w3-text-black,.w3-hover-text-black:hover{color:#000!important}
.w3-text-grey,.w3-hover-text-grey:hover,.w3-text-gray,.w3-hover-text-gray:hover{color:#757575!important}
.w3-text-light-grey,.w3-hover-text-light-grey:hover,.w3-text-light-gray,.w3-hover-text-light-gray:hover{color:#f1f1f1!important}
.w3-text-dark-grey,.w3-hover-text-dark-grey:hover,.w3-text-dark-gray,.w3-hover-text-dark-gray:hover{color:#3a3a3a!important}
.w3-border-amber,.w3-hover-border-amber:hover{border-color:#ffc107!important}
.w3-border-aqua,.w3-hover-border-aqua:hover{border-color:#00ffff!important}
.w3-border-blue,.w3-hover-border-blue:hover{border-color:#2196F3!important}
.w3-border-light-blue,.w3-hover-border-light-blue:hover{border-color:#87CEEB!important}
.w3-border-brown,.w3-hover-border-brown:hover{border-color:#795548!important}
.w3-border-cyan,.w3-hover-border-cyan:hover{border-color:#00bcd4!important}
.w3-border-blue-grey,.w3-hover-border-blue-grey:hover,.w3-border-blue-gray,.w3-hover-border-blue-gray:hover{border-color:#607d8b!important}
.w3-border-green,.w3-hover-border-green:hover{border-color:#4CAF50!important}
.w3-border-light-green,.w3-hover-border-light-green:hover{border-color:#8bc34a!important}
.w3-border-indigo,.w3-hover-border-indigo:hover{border-color:#3f51b5!important}
.w3-border-khaki,.w3-hover-border-khaki:hover{border-color:#f0e68c!important}
.w3-border-lime,.w3-hover-border-lime:hover{border-color:#cddc39!important}
.w3-border-orange,.w3-hover-border-orange:hover{border-color:#ff9800!important}
.w3-border-deep-orange,.w3-hover-border-deep-orange:hover{border-color:#ff5722!important}
.w3-border-pink,.w3-hover-border-pink:hover{border-color:#e91e63!important}
.w3-border-purple,.w3-hover-border-purple:hover{border-color:#9c27b0!important}
.w3-border-deep-purple,.w3-hover-border-deep-purple:hover{border-color:#673ab7!important}
.w3-border-red,.w3-hover-border-red:hover{border-color:#f44336!important}
.w3-border-sand,.w3-hover-border-sand:hover{border-color:#fdf5e6!important}
.w3-border-teal,.w3-hover-border-teal:hover{border-color:#009688!important}
.w3-border-yellow,.w3-hover-border-yellow:hover{border-color:#ffeb3b!important}
.w3-border-white,.w3-hover-border-white:hover{border-color:#fff!important}
.w3-border-black,.w3-hover-border-black:hover{border-color:#000!important}
.w3-border-grey,.w3-hover-border-grey:hover,.w3-border-gray,.w3-hover-border-gray:hover{border-color:#9e9e9e!important}
.w3-border-light-grey,.w3-hover-border-light-grey:hover,.w3-border-light-gray,.w3-hover-border-light-gray:hover{border-color:#f1f1f1!important}
.w3-border-dark-grey,.w3-hover-border-dark-grey:hover,.w3-border-dark-gray,.w3-hover-border-dark-gray:hover{border-color:#616161!important}
.w3-border-pale-red,.w3-hover-border-pale-red:hover{border-color:#ffe7e7!important}.w3-border-pale-green,.w3-hover-border-pale-green:hover{border-color:#e7ffe7!important}
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}

View File

@ -1,5 +1,5 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "🦟", "emoji": "🦟",
"previous": "&O0huuEgL/UQC9bkUfhPOyZFo9eRiz+koOkba6QwCGNA=.sha256" "previous": "&TegdzvFE+im94shygaHkgDYSaSrwY2h0OKUXSRPBQDM=.sha256"
} }

View File

@ -67,6 +67,9 @@ tfrpc.register(function getHash(id, message) {
tfrpc.register(function setHash(hash) { tfrpc.register(function setHash(hash) {
return app.setHash(hash); return app.setHash(hash);
}); });
ssb.addEventListener('message', async function(id) {
await tfrpc.rpc.notifyNewMessage(id);
});
tfrpc.register(async function store_blob(blob) { tfrpc.register(async function store_blob(blob) {
if (Array.isArray(blob)) { if (Array.isArray(blob)) {
blob = Uint8Array.from(blob); blob = Uint8Array.from(blob);
@ -82,24 +85,19 @@ tfrpc.register(async function store_message(message) {
tfrpc.register(function apps() { tfrpc.register(function apps() {
return core.apps(); return core.apps();
}); });
tfrpc.register(function getActiveIdentity() {
return ssb.getActiveIdentity();
});
tfrpc.register(async function try_decrypt(id, content) { tfrpc.register(async function try_decrypt(id, content) {
return await ssb.privateMessageDecrypt(id, content); return await ssb.privateMessageDecrypt(id, content);
}); });
core.register('onMessage', async function (id) { ssb.addEventListener('broadcasts', async function() {
await tfrpc.rpc.notifyNewMessage(id);
});
core.register('onBroadcastsChanged', async function () {
await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts()); 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()); await tfrpc.rpc.set('connections', await ssb.connections());
}); });
async function main() { async function main() {
if (typeof database !== 'undefined') { if (typeof(database) !== 'undefined') {
g_database = await database('ssb'); g_database = await database('ssb');
} }
await app.setDocument(utf8Decode(await getFile('index.html'))); await app.setDocument(utf8Decode(await getFile('index.html')));

File diff suppressed because one or more lines are too long

View File

@ -1,14 +1,12 @@
<!doctype html> <!DOCTYPE html>
<html style="color: #fff"> <html style="color: #fff">
<head> <head>
<title>Tilde Friends</title> <title>Tilde Friends</title>
<base target="_top" /> <base target="_top">
</head> </head>
<body> <body>
<tf-issues-app /> <tf-issues-app/>
<script> <script>window.litDisableBundleWarning = true;</script>
window.litDisableBundleWarning = true;
</script>
<script src="commonmark.min.js"></script> <script src="commonmark.min.js"></script>
<script src="commonmark-linkify.js" type="module"></script> <script src="commonmark-linkify.js" type="module"></script>
<script src="script.js" type="module"></script> <script src="script.js" type="module"></script>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -4,6 +4,43 @@ import * as tfutils from './tf-utils.js';
const k_project = '%Hr+4xEVtjplidSKBlRWi4Aw/0Tfw7B+1OR9BzlDKmOI=.sha256'; const k_project = '%Hr+4xEVtjplidSKBlRWi4Aw/0Tfw7B+1OR9BzlDKmOI=.sha256';
class TfIdPickerElement extends LitElement {
static get properties() {
return {
ids: {type: Array},
selected: {type: String},
};
}
constructor() {
super();
this.load();
}
async load() {
this.selected = await tfrpc.rpc.localStorageGet('whoami');
this.ids = (await tfrpc.rpc.getIdentities()) || [];
}
changed(event) {
this.selected = event.srcElement.value;
tfrpc.rpc.localStorageSet('whoami', this.selected);
}
render() {
if (this.ids) {
return html`
<select @change=${this.changed} style="max-width: 100%">
${(this.ids).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)}
</select>
`;
} else {
return html`<div>Loading...</div>`;
}
}
}
customElements.define('tf-id-picker', TfIdPickerElement);
class TfComposeElement extends LitElement { class TfComposeElement extends LitElement {
static get properties() { static get properties() {
return { return {
@ -20,15 +57,13 @@ class TfComposeElement extends LitElement {
} }
submit() { submit() {
this.dispatchEvent( this.dispatchEvent(new CustomEvent('tf-submit', {
new CustomEvent('tf-submit', { bubbles: true,
bubbles: true, composed: true,
composed: true, detail: {
detail: { value: this.renderRoot.getElementById('input').value,
value: this.renderRoot.getElementById('input').value, },
}, }));
})
);
this.renderRoot.getElementById('input').value = ''; this.renderRoot.getElementById('input').value = '';
this.input(); this.input();
} }
@ -61,21 +96,18 @@ class TfIssuesAppElement extends LitElement {
async load() { async load() {
let issues = {}; let issues = {};
let messages = await tfrpc.rpc.query( let messages = await tfrpc.rpc.query(`
` WITH issues AS (SELECT messages.* FROM messages_refs JOIN messages ON
WITH issues AS (SELECT messages.id, json(messages.content) AS content, messages.author, messages.timestamp FROM messages_refs JOIN messages ON
messages.id = messages_refs.message messages.id = messages_refs.message
WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'), WHERE messages_refs.ref = ? AND json_extract(messages.content, '$.type') = 'issue'),
edits AS (SELECT messages.id, json(messages.content) AS content, messages.author, messages.timestamp FROM issues JOIN messages_refs ON edits AS (SELECT messages.* FROM issues JOIN messages_refs ON
issues.id = messages_refs.ref JOIN messages ON issues.id = messages_refs.ref JOIN messages ON
messages.id = messages_refs.message messages.id = messages_refs.message
WHERE json_extract(messages.content, '$.type') IN ('issue-edit', 'post')) WHERE json_extract(messages.content, '$.type') IN ('issue-edit', 'post'))
SELECT * FROM issues SELECT * FROM issues
UNION UNION
SELECT * FROM edits ORDER BY timestamp SELECT * FROM edits ORDER BY timestamp
`, `, [k_project]);
[k_project]
);
for (let message of messages) { for (let message of messages) {
let content = JSON.parse(message.content); let content = JSON.parse(message.content);
switch (content.type) { switch (content.type) {
@ -91,7 +123,7 @@ class TfIssuesAppElement extends LitElement {
break; break;
case 'issue-edit': case 'issue-edit':
case 'post': case 'post':
for (let issue of content.issues || []) { for (let issue of (content.issues || [])) {
if (issues[issue.link]) { if (issues[issue.link]) {
if (issue.open !== undefined) { if (issue.open !== undefined) {
issues[issue.link].open = issue.open; issues[issue.link].open = issue.open;
@ -104,9 +136,7 @@ class TfIssuesAppElement extends LitElement {
break; break;
} }
} }
this.issues = Object.values(issues).sort( this.issues = Object.values(issues).sort((x, y) => (y.open - x.open) || (y.created - x.created));
(x, y) => y.open - x.open || y.created - x.created
);
if (this.selected) { if (this.selected) {
for (let issue of this.issues) { for (let issue of this.issues) {
if (issue.id == this.selected.id) { if (issue.id == this.selected.id) {
@ -120,20 +150,11 @@ class TfIssuesAppElement extends LitElement {
return html` return html`
<tr> <tr>
<td>${issue.open ? '☐ open' : '☑ closed'}</td> <td>${issue.open ? '☐ open' : '☑ closed'}</td>
<td <td style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis">${issue.author}</td>
style="max-width: 8em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis" <td style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer" @click=${() => this.selected = issue}>
>
${issue.author}
</td>
<td
style="max-width: 40em; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; cursor: pointer"
@click=${() => (this.selected = issue)}
>
${issue.text.split('\n')?.[0]} ${issue.text.split('\n')?.[0]}
</td> </td>
<td> <td>${new Date(issue.updated ?? issue.created).toLocaleDateString()}</td>
${new Date(issue.updated ?? issue.created).toLocaleDateString()}
</td>
</tr> </tr>
`; `;
} }
@ -149,22 +170,14 @@ class TfIssuesAppElement extends LitElement {
<div>${new Date(update.timestamp).toLocaleString()}</div> <div>${new Date(update.timestamp).toLocaleString()}</div>
<div>${update.author}</div> <div>${update.author}</div>
<div>${message}</div> <div>${message}</div>
<div> <div>${update.open !== undefined ? (update.open ? 'issue opened' : 'issue closed') : undefined}</div>
${update.open !== undefined
? update.open
? 'issue opened'
: 'issue closed'
: undefined}
</div>
</div> </div>
`; `;
} }
async set_open(id, open) { async set_open(id, open) {
if ( if (confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`)) {
confirm(`Are you sure you want to ${open ? 'open' : 'close'} this issue?`) let whoami = this.shadowRoot.getElementById('picker').selected;
) {
let whoami = await tfrpc.rpc.getActiveIdentity();
await tfrpc.rpc.appendMessage(whoami, { await tfrpc.rpc.appendMessage(whoami, {
type: 'issue-edit', type: 'issue-edit',
issues: [ issues: [
@ -179,7 +192,7 @@ class TfIssuesAppElement extends LitElement {
} }
async create_issue(event) { async create_issue(event) {
let whoami = await tfrpc.rpc.getActiveIdentity(); let whoami = this.shadowRoot.getElementById('picker').selected;
await tfrpc.rpc.appendMessage(whoami, { await tfrpc.rpc.appendMessage(whoami, {
type: 'issue', type: 'issue',
project: k_project, project: k_project,
@ -189,14 +202,12 @@ class TfIssuesAppElement extends LitElement {
} }
async reply_to_issue(event) { async reply_to_issue(event) {
let whoami = await tfrpc.rpc.getActiveIdentity(); let whoami = this.shadowRoot.getElementById('picker').selected;
await tfrpc.rpc.appendMessage(whoami, { await tfrpc.rpc.appendMessage(whoami, {
type: 'post', type: 'post',
text: event.detail.value, text: event.detail.value,
root: this.selected.id, root: this.selected.id,
branch: this.selected.updates.length branch: this.selected.updates.length ? this.selected.updates[this.selected.updates.length - 1].id : this.selected.id,
? this.selected.updates[this.selected.updates.length - 1].id
: this.selected.id,
issues: [ issues: [
{ {
link: this.selected.id, link: this.selected.id,
@ -207,23 +218,24 @@ class TfIssuesAppElement extends LitElement {
} }
render() { render() {
let header = html` <h1>Tilde Friends Issues</h1> `; let header = html`
<h1>Tilde Friends Issues</h1>
<tf-id-picker id="picker"></tf-id-picker>
`;
if (this.selected) { if (this.selected) {
return html` return html`
${header} ${header}
<div> <div>
<input type="button" value="Back" @click=${() => (this.selected = undefined)}></input> <input type="button" value="Back" @click=${() => this.selected = undefined}></input>
${ ${this.selected.open ?
this.selected.open html`<input type="button" value="Close Issue" @click=${() => this.set_open(this.selected.id, false)}></input>` :
? html`<input type="button" value="Close Issue" @click=${() => this.set_open(this.selected.id, false)}></input>` html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>`}
: html`<input type="button" value="Reopen Issue" @click=${() => this.set_open(this.selected.id, true)}></input>`
}
</div> </div>
<div>${new Date(this.selected.created).toLocaleString()}</div> <div>${new Date(this.selected.created).toLocaleString()}</div>
<div>${this.selected.author}</div> <div>${this.selected.author}</div>
<div>${this.selected.id}</div> <div>${this.selected.id}</div>
<div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div> <div>${unsafeHTML(tfutils.markdown(this.selected.text))}</div>
${this.selected.updates.map((x) => this.render_update(x))} ${this.selected.updates.map(x => this.render_update(x))}
<tf-compose @tf-submit=${this.reply_to_issue}></tf-compose> <tf-compose @tf-submit=${this.reply_to_issue}></tf-compose>
`; `;
} else { } else {
@ -238,7 +250,7 @@ class TfIssuesAppElement extends LitElement {
<th>Title</th> <th>Title</th>
<th>Date</th> <th>Date</th>
</tr> </tr>
${this.issues.map((x) => this.render_issue_table_row(x))} ${this.issues.map(x => this.render_issue_table_row(x))}
</table> </table>
`; `;
} }

View File

@ -1,38 +1,20 @@
import * as linkify from './commonmark-linkify.js'; import * as linkify from './commonmark-linkify.js';
var reUnsafeProtocol = /^javascript:|vbscript:|file:|data:/i;
var reSafeDataProtocol = /^data:image\/(?:png|gif|jpeg|webp)/i;
var potentiallyUnsafe = function (url) {
return reUnsafeProtocol.test(url) && !reSafeDataProtocol.test(url);
};
function image(node, entering) { function image(node, entering) {
if ( if (node.firstChild?.type === 'text' &&
node.firstChild?.type === 'text' && node.firstChild.literal.startsWith('video:')) {
node.firstChild.literal.startsWith('video:')
) {
if (entering) { if (entering) {
this.lit( this.lit('<video style="max-width: 100%; max-height: 480px" title="' + this.esc(node.firstChild?.literal) + '" controls>');
'<video style="max-width: 100%; max-height: 480px" title="' +
this.esc(node.firstChild?.literal) +
'" controls>'
);
this.lit('<source src="' + this.esc(node.destination) + '"></source>'); this.lit('<source src="' + this.esc(node.destination) + '"></source>');
this.disableTags += 1; this.disableTags += 1;
} else { } else {
this.disableTags -= 1; this.disableTags -= 1;
this.lit('</video>'); this.lit('</video>');
} }
} else if ( } else if (node.firstChild?.type === 'text' &&
node.firstChild?.type === 'text' && node.firstChild.literal.startsWith('audio:')) {
node.firstChild.literal.startsWith('audio:')
) {
if (entering) { if (entering) {
this.lit( this.lit('<audio style="height: 32px; max-width: 100%" title="' + this.esc(node.firstChild?.literal) + '" controls>');
'<audio style="height: 32px; max-width: 100%" title="' +
this.esc(node.firstChild?.literal) +
'" controls>'
);
this.lit('<source src="' + this.esc(node.destination) + '"></source>'); this.lit('<source src="' + this.esc(node.destination) + '"></source>');
this.disableTags += 1; this.disableTags += 1;
} else { } else {
@ -42,11 +24,7 @@ function image(node, entering) {
} else { } else {
if (entering) { if (entering) {
if (this.disableTags === 0) { if (this.disableTags === 0) {
this.lit( this.lit('<div class="img_caption">' + this.esc(node.firstChild?.literal || node.destination) + '</div>');
'<div class="img_caption">' +
this.esc(node.firstChild?.literal || node.destination) +
'</div>'
);
if (this.options.safe && potentiallyUnsafe(node.destination)) { if (this.options.safe && potentiallyUnsafe(node.destination)) {
this.lit('<img src="" alt="'); this.lit('<img src="" alt="');
} else { } else {
@ -67,8 +45,8 @@ function image(node, entering) {
} }
export function markdown(md) { export function markdown(md) {
var reader = new commonmark.Parser(); var reader = new commonmark.Parser({safe: true});
var writer = new commonmark.HtmlRenderer({safe: true}); var writer = new commonmark.HtmlRenderer();
writer.image = image; writer.image = image;
var parsed = reader.parse(md || ''); var parsed = reader.parse(md || '');
parsed = linkify.transform(parsed); parsed = linkify.transform(parsed);
@ -78,20 +56,14 @@ export function markdown(md) {
node = event.node; node = event.node;
if (event.entering) { if (event.entering) {
if (node.type == 'link') { if (node.type == 'link') {
if ( if (node.destination.startsWith('@') &&
node.destination.startsWith('@') && node.destination.endsWith('.ed25519')) {
node.destination.endsWith('.ed25519')
) {
node.destination = '#' + node.destination; node.destination = '#' + node.destination;
} else if ( } else if (node.destination.startsWith('%') &&
node.destination.startsWith('%') && node.destination.endsWith('.sha256')) {
node.destination.endsWith('.sha256')
) {
node.destination = '#' + node.destination; node.destination = '#' + node.destination;
} else if ( } else if (node.destination.startsWith('&') &&
node.destination.startsWith('&') && node.destination.endsWith('.sha256')) {
node.destination.endsWith('.sha256')
) {
node.destination = '/' + node.destination + '/view'; node.destination = '/' + node.destination + '/view';
} }
} else if (node.type == 'image') { } else if (node.type == 'image') {

View File

@ -1,5 +1,5 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "📝", "emoji": "📝",
"previous": "&5LpOTEnor/rYFk3axyfmmehAoq9aEwNQRH4jwNhRQ7o=.sha256" "previous": "&2hdIDbBrAg63T2X1MzdGSF7yiqHvlnfF0PnInQLp0DA=.sha256"
} }

View File

@ -47,7 +47,7 @@ tfrpc.register(async function get_blob(id) {
}); });
let g_new_message_resolve; 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; g_new_message_resolve = resolve;
}); });
@ -55,9 +55,9 @@ function new_message() {
return g_new_message_promise; return g_new_message_promise;
} }
core.register('onMessage', function (id) { ssb.addEventListener('message', function(id) {
let resolve = g_new_message_resolve; 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; g_new_message_resolve = resolve;
}); });
if (resolve) { if (resolve) {
@ -104,7 +104,8 @@ async function process_message(whoami, collection, message, kind, parent) {
if (!x) { if (!x) {
return; return;
} }
if (content.type !== kind || (parent && content.parent !== parent)) { if (content.type !== kind ||
(parent && content.parent !== parent)) {
return; return;
} }
} }
@ -112,10 +113,7 @@ async function process_message(whoami, collection, message, kind, parent) {
if (content?.tombstone) { if (content?.tombstone) {
delete collection[content.key]; delete collection[content.key];
} else { } else {
collection[content.key] = Object.assign( collection[content.key] = Object.assign(collection[content.key] || {}, content);
collection[content.key] || {},
content
);
} }
} else { } else {
collection[message.id] = Object.assign(content, {id: message.id}); collection[message.id] = Object.assign(content, {id: message.id});
@ -127,29 +125,20 @@ tfrpc.register(async function collection(ids, kind, parent, max_rowid, data) {
let whoami = await ssb.getIdentities(); let whoami = await ssb.getIdentities();
data = data ?? {}; data = data ?? {};
let rowid = 0; let rowid = 0;
await ssb.sqlAsync( await ssb.sqlAsync('SELECT MAX(rowid) AS rowid FROM messages', [], function(row) {
'SELECT MAX(rowid) AS rowid FROM messages', rowid = row.rowid;
[], });
function (row) {
rowid = row.rowid;
}
);
while (true) { while (true) {
if (rowid == max_rowid) { if (rowid == max_rowid) {
await new_message(); await new_message();
await ssb.sqlAsync( await ssb.sqlAsync('SELECT MAX(rowid) AS rowid FROM messages', [], function(row) {
'SELECT MAX(rowid) AS rowid FROM messages', rowid = row.rowid;
[], });
function (row) {
rowid = row.rowid;
}
);
} }
let modified = false; let modified = false;
let rows = []; let rows = [];
await ssb.sqlAsync( await ssb.sqlAsync(`
`
SELECT messages.id, author, content, timestamp SELECT messages.id, author, content, timestamp
FROM messages FROM messages
JOIN json_each(?1) AS id ON messages.author = id.value JOIN json_each(?1) AS id ON messages.author = id.value
@ -161,10 +150,9 @@ tfrpc.register(async function collection(ids, kind, parent, max_rowid, data) {
content LIKE '"%') content LIKE '"%')
`, `,
[JSON.stringify(ids), max_rowid ?? -1, rowid, kind, parent], [JSON.stringify(ids), max_rowid ?? -1, rowid, kind, parent],
function (row) { function(row) {
rows.push(row); rows.push(row);
} });
);
max_rowid = rowid; max_rowid = rowid;
for (let row of rows) { for (let row of rows) {
if (await process_message(whoami, data, row, kind, parent)) { if (await process_message(whoami, data, row, kind, parent)) {

File diff suppressed because one or more lines are too long

View File

@ -1,14 +1,12 @@
<!doctype html> <!DOCTYPE html>
<html> <html>
<head> <head>
<base target="_top" /> <base target="_top">
</head> </head>
<body style="color: #fff"> <body style="color: #fff">
<tf-journal-app></tf-journal-app> <tf-journal-app></tf-journal-app>
<script src="commonmark.min.js"></script> <script src="commonmark.min.js"></script>
<script> <script>window.litDisableBundleWarning = true;</script>
window.litDisableBundleWarning = true;
</script>
<script src="tf-journal-app.js" type="module"></script> <script src="tf-journal-app.js" type="module"></script>
<script src="tf-journal-entry.js" type="module"></script> <script src="tf-journal-entry.js" type="module"></script>
<script src="tf-id-picker.js" type="module"></script> <script src="tf-id-picker.js" type="module"></script>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -2,8 +2,8 @@ import {LitElement, html} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.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 { class TfIdentityPickerElement extends LitElement {
static get properties() { static get properties() {
return { return {
@ -19,22 +19,15 @@ class TfIdentityPickerElement extends LitElement {
changed(event) { changed(event) {
this.selected = event.srcElement.value; this.selected = event.srcElement.value;
this.dispatchEvent( this.dispatchEvent(new Event('change', {
new Event('change', { srcElement: this,
srcElement: this, }));
})
);
} }
render() { render() {
return html` return html`
<select @change=${this.changed} style="max-width: 100%"> <select @change=${this.changed} style="max-width: 100%">
${(this.ids ?? []).map( ${(this.ids ?? []).map(id => html`<option ?selected=${id == this.selected} value=${id}>${id}</option>`)}
(id) =>
html`<option ?selected=${id == this.selected} value=${id}>
${id}
</option>`
)}
</select> </select>
`; `;
} }

View File

@ -28,14 +28,9 @@ class TfJournalAppElement extends LitElement {
async read_journals() { async read_journals() {
let max_rowid; let max_rowid;
let journals; let journals;
while (true) { while (true)
[max_rowid, journals] = await tfrpc.rpc.collection( {
[this.whoami], [max_rowid, journals] = await tfrpc.rpc.collection([this.whoami], 'journal-entry', undefined, max_rowid, journals);
'journal-entry',
undefined,
max_rowid,
journals
);
this.journals = Object.assign({}, journals); this.journals = Object.assign({}, journals);
console.log('JOURNALS', this.journals); console.log('JOURNALS', this.journals);
} }
@ -57,11 +52,7 @@ class TfJournalAppElement extends LitElement {
}; };
message.recps = [this.whoami]; message.recps = [this.whoami];
print(message); print(message);
message = await tfrpc.rpc.encrypt( message = await tfrpc.rpc.encrypt(this.whoami, message.recps, JSON.stringify(message));
this.whoami,
message.recps,
JSON.stringify(message)
);
print(message); print(message);
await tfrpc.rpc.appendMessage(this.whoami, message); await tfrpc.rpc.appendMessage(this.whoami, message);
} }
@ -71,17 +62,12 @@ class TfJournalAppElement extends LitElement {
let self = this; let self = this;
return html` return html`
<div> <div>
<tf-id-picker <tf-id-picker .ids=${this.ids} selected=${this.whoami} @change=${this.on_whoami_changed}></tf-id-picker>
.ids=${this.ids}
selected=${this.whoami}
@change=${this.on_whoami_changed}
></tf-id-picker>
</div> </div>
<tf-journal-entry <tf-journal-entry
whoami=${this.whoami} whoami=${this.whoami}
.journals=${this.journals} .journals=${this.journals}
@publish=${this.on_journal_publish} @publish=${this.on_journal_publish}></tf-journal-entry>
></tf-journal-entry>
`; `;
} }
} }

View File

@ -18,8 +18,8 @@ class TfJournalEntryElement extends LitElement {
} }
markdown(md) { markdown(md) {
var reader = new commonmark.Parser(); var reader = new commonmark.Parser({safe: true});
var writer = new commonmark.HtmlRenderer({safe: true}); var writer = new commonmark.HtmlRenderer();
var parsed = reader.parse(md || ''); var parsed = reader.parse(md || '');
return writer.render(parsed); return writer.render(parsed);
} }
@ -30,15 +30,13 @@ class TfJournalEntryElement extends LitElement {
async on_publish() { async on_publish() {
console.log('publish', this.text); console.log('publish', this.text);
this.dispatchEvent( this.dispatchEvent(new CustomEvent('publish', {
new CustomEvent('publish', { bubbles: true,
bubbles: true, detail: {
detail: { key: this.shadowRoot.getElementById('date_picker').value,
key: this.shadowRoot.getElementById('date_picker').value, text: this.text,
text: this.text, },
}, }));
})
);
} }
back_dates(count) { back_dates(count) {
@ -65,30 +63,19 @@ class TfJournalEntryElement extends LitElement {
console.log('RENDER ENTRY', this.key, this.journals?.[this.key]); console.log('RENDER ENTRY', this.key, this.journals?.[this.key]);
return html` return html`
<select id="date_picker" @change=${this.on_date_change}> <select id="date_picker" @change=${this.on_date_change}>
${this.back_dates(10).map( ${this.back_dates(10).map(x => html`
(x) => html` <option value=${x}>${x}</option> ` <option value=${x}>${x}</option>
)} `)}
</select> </select>
<div style="display: inline-flex; flex-direction: row"> <div style="display: inline-flex; flex-direction: row">
<button <button ?disabled=${this.text == this.journals?.[this.key]?.text} @click=${this.on_publish}>Publish</button>
?disabled=${this.text == this.journals?.[this.key]?.text}
@click=${this.on_publish}
>
Publish
</button>
<button @click=${this.on_discard}>Discard</button> <button @click=${this.on_discard}>Discard</button>
</div> </div>
<div style="display: flex; flex-direction: row"> <div style="display: flex; flex-direction: row">
<textarea <textarea
style="flex: 1 1; min-height: 10em" style="flex: 1 1; min-height: 10em"
@input=${this.on_edit} @input=${this.on_edit} .value=${this.text ?? this.journals?.[this.key]?.text ?? ''}></textarea>
.value=${this.text ?? this.journals?.[this.key]?.text ?? ''} <div style="flex: 1 1">${unsafeHTML(this.markdown(this.text ?? this.journals?.[this.key]?.text))}</div>
></textarea>
<div style="flex: 1 1">
${unsafeHTML(
this.markdown(this.text ?? this.journals?.[this.key]?.text)
)}
</div>
</div> </div>
`; `;
} }

View File

@ -1,5 +0,0 @@
{
"type": "tildefriends-app",
"emoji": "🚪",
"previous": "&HXCdDG8gGYXElTyEFbg85jqa6lDXNL2ENPIA9UoJNbI=.sha256"
}

View File

@ -1,13 +0,0 @@
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();

View File

@ -1,5 +1,4 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "👟", "emoji": "👟"
"previous": "&lYZRnT2UGQxXxYISbuaZewik9AuxBpcJumakwrePw5c=.sha256"
} }

View File

@ -1,14 +1,12 @@
<!doctype html> <!DOCTYPE html>
<html style="color: #fff"> <html style="color: #fff">
<head> <head>
<title>Tilde Friends</title> <title>Tilde Friends</title>
<base target="_top" /> <base target="_top">
</head> </head>
<body> <body>
<tf-sneaker-app /> <tf-sneaker-app/>
<script> <script>window.litDisableBundleWarning = true;</script>
window.litDisableBundleWarning = true;
</script>
<script src="filesaver.min.js"></script> <script src="filesaver.min.js"></script>
<script src="jszip.min.js"></script> <script src="jszip.min.js"></script>
<script src="script.js" type="module"></script> <script src="script.js" type="module"></script>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -19,8 +19,7 @@ class TfSneakerAppElement extends LitElement {
async search() { async search() {
let q = this.renderRoot.getElementById('search').value; let q = this.renderRoot.getElementById('search').value;
let result = await tfrpc.rpc.query( let result = await tfrpc.rpc.query(`
`
SELECT messages.author AS id, json_extract(messages.content, '$.name') AS name SELECT messages.author AS id, json_extract(messages.content, '$.name') AS name
FROM messages_fts(?) FROM messages_fts(?)
JOIN messages ON messages.rowid = messages_fts.rowid JOIN messages ON messages.rowid = messages_fts.rowid
@ -32,17 +31,15 @@ class TfSneakerAppElement extends LitElement {
HAVING MAX(messages.sequence) HAVING MAX(messages.sequence)
ORDER BY COUNT(*) DESC ORDER BY COUNT(*) DESC
`, `,
[`"${q.replaceAll('"', '""')}"`] [`"${q.replaceAll('"', '""')}"`]);
); this.feeds = Object.fromEntries(result.map(x => [x.id, x.name]));
this.feeds = Object.fromEntries(result.map((x) => [x.id, x.name]));
} }
format_message(message) { format_message(message) {
const k_flag_sequence_before_author = 1;
let out = { let out = {
previous: message.previous ?? null, previous: message.previous ?? null,
}; };
if (message.flags & k_flag_sequence_before_author) { if (message.sequence_before_author) {
out.sequence = message.sequence; out.sequence = message.sequence;
out.author = message.author; out.author = message.author;
} else { } else {
@ -73,104 +70,24 @@ class TfSneakerAppElement extends LitElement {
return true; return true;
} }
if ( if (startsWith(data, [0xff, 0xd8, 0xff, 0xdb]) ||
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, 0xe0, 0x00, 0x10, 0x4a, 0x46, 0x49, 0x46, 0x00, 0x01]
) ||
startsWith(data, [0xff, 0xd8, 0xff, 0xee]) || startsWith(data, [0xff, 0xd8, 0xff, 0xee]) ||
startsWith(data, [ startsWith(data, [0xff, 0xd8, 0xff, 0xe1, null, null, 0x45, 0x78, 0x69, 0x66, 0x00, 0x00])) {
0xff,
0xd8,
0xff,
0xe1,
null,
null,
0x45,
0x78,
0x69,
0x66,
0x00,
0x00,
])
) {
return '.jpg'; return '.jpg';
} else if ( } else if (startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) {
startsWith(data, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])
) {
return '.png'; return '.png';
} else if ( } else if (startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) ||
startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x37, 0x61]) || startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])) {
startsWith(data, [0x47, 0x49, 0x46, 0x38, 0x39, 0x61])
) {
return '.gif'; return '.gif';
} else if ( } else if (startsWith(data, [0x52, 0x49, 0x46, 0x46, null, null, null, null, 0x57, 0x45, 0x42, 0x50])) {
startsWith(data, [
0x52,
0x49,
0x46,
0x46,
null,
null,
null,
null,
0x57,
0x45,
0x42,
0x50,
])
) {
return '.webp'; return '.webp';
} else if (startsWith(data, [0x3c, 0x73, 0x76, 0x67])) { } else if (startsWith(data, [0x3c, 0x73, 0x76, 0x67])) {
return '.svg'; return '.svg';
} else if ( } else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
startsWith(data, [
null,
null,
null,
null,
0x66,
0x74,
0x79,
0x70,
0x6d,
0x70,
0x34,
0x32,
])
) {
return '.mp3'; return '.mp3';
} else if ( } else if (startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x69, 0x73, 0x6f, 0x6d]) ||
startsWith(data, [ startsWith(data, [null, null, null, null, 0x66, 0x74, 0x79, 0x70, 0x6d, 0x70, 0x34, 0x32])) {
null,
null,
null,
null,
0x66,
0x74,
0x79,
0x70,
0x69,
0x73,
0x6f,
0x6d,
]) ||
startsWith(data, [
null,
null,
null,
null,
0x66,
0x74,
0x79,
0x70,
0x6d,
0x70,
0x34,
0x32,
])
) {
return '.mp4'; return '.mp4';
} else { } else {
return '.bin'; return '.bin';
@ -181,34 +98,17 @@ class TfSneakerAppElement extends LitElement {
let all_messages = ''; let all_messages = '';
let sequence = -1; let sequence = -1;
let messages_done = 0; let messages_done = 0;
let messages_max = ( let messages_max = (await tfrpc.rpc.query('SELECT MAX(sequence) AS total FROM messages WHERE author = ?', [id]))[0].total;
await tfrpc.rpc.query(
'SELECT MAX(sequence) AS total FROM messages WHERE author = ?',
[id]
)
)[0].total;
while (true) { while (true) {
let messages = await tfrpc.rpc.query( let messages = await tfrpc.rpc.query(
` 'SELECT * FROM messages WHERE author = ? AND SEQUENCE > ? ORDER BY sequence LIMIT 100',
SELECT author, id, sequence, timestamp, hash, json(content) AS content, signature, flags [id, sequence]
FROM messages
WHERE author = ? AND SEQUENCE > ?
ORDER BY sequence LIMIT 100
`,
[id, sequence]
); );
if (messages?.length) { if (messages?.length) {
all_messages += all_messages += messages.map(x => JSON.stringify(this.format_message(x))).join('\n') + '\n';
messages
.map((x) => JSON.stringify(this.format_message(x)))
.join('\n') + '\n';
sequence = messages[messages.length - 1].sequence; sequence = messages[messages.length - 1].sequence;
messages_done += messages.length; messages_done += messages.length;
this.progress = { this.progress = {name: 'messages', value: messages_done, max: messages_max};
name: 'messages',
value: messages_done,
max: messages_max,
};
} else { } else {
break; break;
} }
@ -222,8 +122,7 @@ class TfSneakerAppElement extends LitElement {
FROM messages FROM messages
JOIN messages_refs ON messages.id = messages_refs.message JOIN messages_refs ON messages.id = messages_refs.message
WHERE messages.author = ? AND messages_refs.ref LIKE '&%.sha256'`, WHERE messages.author = ? AND messages_refs.ref LIKE '&%.sha256'`,
[id] [id]);
);
let blobs_done = 0; let blobs_done = 0;
for (let row of blobs) { for (let row of blobs) {
this.progress = {name: 'blobs', value: blobs_done, max: blobs.length}; this.progress = {name: 'blobs', value: blobs_done, max: blobs.length};
@ -234,10 +133,7 @@ class TfSneakerAppElement extends LitElement {
console.log(`Failed to get ${row.id}: ${e.message}`); console.log(`Failed to get ${row.id}: ${e.message}`);
} }
if (blob) { if (blob) {
zip.file( zip.file(`blob/classic/${this.sanitize(row.id)}${this.guess_ext(blob)}`, new Uint8Array(blob));
`blob/classic/${this.sanitize(row.id)}${this.guess_ext(blob)}`,
new Uint8Array(blob)
);
} }
blobs_done++; blobs_done++;
} }
@ -265,7 +161,7 @@ class TfSneakerAppElement extends LitElement {
file = await zip.loadAsync(file); file = await zip.loadAsync(file);
let messages = []; let messages = [];
let blobs = []; let blobs = [];
file.forEach(function (path, entry) { file.forEach(function(path, entry) {
if (!entry.dir) { if (!entry.dir) {
if (path.startsWith('message/classic/')) { if (path.startsWith('message/classic/')) {
messages.push(entry); messages.push(entry);
@ -285,11 +181,7 @@ class TfSneakerAppElement extends LitElement {
continue; continue;
} }
let message = JSON.parse(line); let message = JSON.parse(line);
this.progress = { this.progress = {name: 'messages', value: progress++, max: total_messages};
name: 'messages',
value: progress++,
max: total_messages,
};
if (await tfrpc.rpc.store_message(message.value)) { if (await tfrpc.rpc.store_message(message.value)) {
success.messages++; success.messages++;
} }
@ -310,13 +202,7 @@ class TfSneakerAppElement extends LitElement {
let progress; let progress;
if (this.progress) { if (this.progress) {
if (this.progress.max) { if (this.progress.max) {
progress = html`<div> progress = html`<div><label for="progress">${this.progress.name}</label><progress value=${this.progress.value} max=${this.progress.max}></progress></div>`;
<label for="progress">${this.progress.name}</label
><progress
value=${this.progress.value}
max=${this.progress.max}
></progress>
</div>`;
} else { } else {
progress = html`<div><span>${this.progress.name}</span></div>`; progress = html`<div><span>${this.progress.name}</span></div>`;
} }
@ -332,17 +218,13 @@ class TfSneakerAppElement extends LitElement {
<input type="text" id="search" @keypress=${this.keypress}></input> <input type="text" id="search" @keypress=${this.keypress}></input>
<input type="button" value="Search Users" @click=${this.search}></input> <input type="button" value="Search Users" @click=${this.search}></input>
<ul> <ul>
${Object.entries(this.feeds).map( ${Object.entries(this.feeds).map(([id, name]) => html`
([id, name]) => html` <li>
<li> ${this.progress ? undefined : html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`}
${this.progress ${name}
? undefined <code style="color: #ccc">${id}</code>
: html`<input type="button" value="Export" @click=${() => this.export(id)}></input>`} </li>
${name} `)}
<code style="color: #ccc">${id}</code>
</li>
`
)}
</ul> </ul>
`; `;
} }

View File

@ -1,5 +1,5 @@
{ {
"type": "tildefriends-app", "type": "tildefriends-app",
"emoji": "🦀", "emoji": "🐌",
"previous": "&LdCd9+kl1DTJIE1+wJ20Yrtpbm15aXvjIBJU3r5Fi38=.sha256" "previous": "&DUxMMCJcuhm6S9jg/eKgEyWodkITu6Tg9g5I5wgLWFU=.sha256"
} }

View File

@ -21,6 +21,9 @@ tfrpc.register(async function createIdentity() {
tfrpc.register(async function getServerIdentity() { tfrpc.register(async function getServerIdentity() {
return ssb.getServerIdentity(); return ssb.getServerIdentity();
}); });
tfrpc.register(async function setServerFollowingMe(id, following) {
return ssb.setServerFollowingMe(id, following);
});
tfrpc.register(async function getIdentities() { tfrpc.register(async function getIdentities() {
return ssb.getIdentities(); return ssb.getIdentities();
}); });
@ -73,7 +76,7 @@ tfrpc.register(function getHash(id, message) {
tfrpc.register(function setHash(hash) { tfrpc.register(function setHash(hash) {
return app.setHash(hash); return app.setHash(hash);
}); });
core.register('onMessage', async function (id) { ssb.addEventListener('message', async function(id) {
await tfrpc.rpc.notifyNewMessage(id); await tfrpc.rpc.notifyNewMessage(id);
}); });
tfrpc.register(async function store_blob(blob) { tfrpc.register(async function store_blob(blob) {
@ -97,29 +100,16 @@ tfrpc.register(async function try_decrypt(id, content) {
tfrpc.register(async function encrypt(id, recipients, content) { tfrpc.register(async function encrypt(id, recipients, content) {
return await ssb.privateMessageEncrypt(id, recipients, content); return await ssb.privateMessageEncrypt(id, recipients, content);
}); });
tfrpc.register(async function getActiveIdentity() { ssb.addEventListener('broadcasts', async function() {
return await ssb.getActiveIdentity();
});
tfrpc.register(async function sync() {
return await ssb.sync();
});
tfrpc.register(async function url() {
return core.url;
});
core.register('onBroadcastsChanged', async function () {
await tfrpc.rpc.set('broadcasts', await ssb.getBroadcasts()); 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()); await tfrpc.rpc.set('connections', await ssb.connections());
}); });
core.register('setActiveIdentity', async function (id) {
await tfrpc.rpc.set('identity', id);
});
async function main() { async function main() {
if (typeof database !== 'undefined') { if (typeof(database) !== 'undefined') {
g_database = await database('ssb'); g_database = await database('ssb');
} }
await app.setDocument(utf8Decode(await getFile('index.html'))); await app.setDocument(utf8Decode(await getFile('index.html')));

View File

@ -1,94 +1,90 @@
function textNode(text) { function textNode(text) {
const node = new commonmark.Node('text', undefined); const node = new commonmark.Node("text", undefined);
node.literal = text; node.literal = text;
return node; return node;
} }
function linkNode(text, link) { function linkNode(text, link) {
const linkNode = new commonmark.Node('link', undefined); const linkNode = new commonmark.Node("link", undefined);
if (link.startsWith('#')) { linkNode.destination = `#q=${encodeURIComponent(link)}`;
linkNode.destination = `#${encodeURIComponent(link)}`; linkNode.appendChild(textNode(text));
} else { return linkNode;
linkNode.destination = link;
}
linkNode.appendChild(textNode(text));
return linkNode;
} }
function splitMatches(text, regexp) { function splitMatches(text, regexp) {
// Regexp must be sticky. // Regexp must be sticky.
regexp = new RegExp(regexp, 'gm'); regexp = new RegExp(regexp, "gm");
let i = 0; let i = 0;
const result = []; const result = [];
let match = regexp.exec(text); let match = regexp.exec(text);
while (match) { while (match) {
const matchText = match[0]; const matchText = match[0];
if (match.index > i) { if (match.index > i) {
result.push([text.substring(i, match.index), false]); result.push([text.substring(i, match.index), false]);
} }
result.push([matchText, true]); result.push([matchText, true]);
i = match.index + matchText.length; i = match.index + matchText.length;
match = regexp.exec(text); match = regexp.exec(text);
} }
if (i < text.length) { if (i < text.length) {
result.push([text.substring(i, text.length), false]); result.push([text.substring(i, text.length), false]);
} }
return result; return result;
} }
const regex = new RegExp('(?:https?://[^ ]+[^ .,])|(?:(?<!\\w)#[\\w-]+)|(?:@[A-Za-z0-9+/]+=.ed25519)|(?:[%&][A-Za-z0-9+/]+=.sha256)'); const regex = new RegExp("(?<!\\w)#[\\w-]+");
function split(textNodes) { function split(textNodes) {
const text = textNodes.map((n) => n.literal).join(''); const text = textNodes.map(n => n.literal).join("");
const parts = splitMatches(text, regex); const parts = splitMatches(text, regex);
return parts.map((part) => { return parts.map(part => {
if (part[1]) { if (part[1]) {
return linkNode(part[0], part[0]); return linkNode(part[0], part[0]);
} else { } else {
return textNode(part[0]); return textNode(part[0]);
} }
}); });
} }
export function transform(parsed) { export function transform(parsed) {
const walker = parsed.walker(); const walker = parsed.walker();
let event; let event;
let nodes = []; let nodes = [];
while ((event = walker.next())) { while ((event = walker.next())) {
const node = event.node; const node = event.node;
if (event.entering && node.type === 'text') { if (event.entering && node.type === "text") {
nodes.push(node); nodes.push(node);
} else { } else {
if (nodes.length > 0) { if (nodes.length > 0) {
split(nodes) split(nodes)
.reverse() .reverse()
.forEach((newNode) => { .forEach(newNode => {
nodes[0].insertAfter(newNode); nodes[0].insertAfter(newNode);
}); });
nodes.forEach((n) => n.unlink()); nodes.forEach(n => n.unlink());
nodes = []; nodes = [];
} }
} }
} }
if (nodes.length > 0) { if (nodes.length > 0) {
split(nodes) split(nodes)
.reverse() .reverse()
.forEach((newNode) => { .forEach(newNode => {
nodes[0].insertAfter(newNode); nodes[0].insertAfter(newNode);
}); });
nodes.forEach((n) => n.unlink()); nodes.forEach(n => n.unlink());
} }
return parsed; return parsed;
} }

View File

@ -0,0 +1,91 @@
function textNode(text) {
const node = new commonmark.Node("text", undefined);
node.literal = text;
return node;
}
function linkNode(text, url) {
const urlNode = new commonmark.Node("link", undefined);
urlNode.destination = url;
urlNode.appendChild(textNode(text));
return urlNode;
}
function splitMatches(text, regexp) {
// Regexp must be sticky.
regexp = new RegExp(regexp, "gm");
let i = 0;
const result = [];
let match = regexp.exec(text);
while (match) {
const matchText = match[0];
if (match.index > i) {
result.push([text.substring(i, match.index), false]);
}
result.push([matchText, true]);
i = match.index + matchText.length;
match = regexp.exec(text);
}
if (i < text.length) {
result.push([text.substring(i, text.length), false]);
}
return result;
}
const urlRegexp = new RegExp("https?://[^ ]+[^ .,]");
function splitURLs(textNodes) {
const text = textNodes.map(n => n.literal).join("");
const parts = splitMatches(text, urlRegexp);
return parts.map(part => {
if (part[1]) {
return linkNode(part[0], part[0]);
} else {
return textNode(part[0]);
}
});
}
export function transform(parsed) {
const walker = parsed.walker();
let event;
let nodes = [];
while ((event = walker.next())) {
const node = event.node;
if (event.entering && node.type === "text") {
nodes.push(node);
} else {
if (nodes.length > 0) {
splitURLs(nodes)
.reverse()
.forEach(newNode => {
nodes[0].insertAfter(newNode);
});
nodes.forEach(n => n.unlink());
nodes = [];
}
}
}
if (nodes.length > 0) {
splitURLs(nodes)
.reverse()
.forEach(newNode => {
nodes[0].insertAfter(newNode);
});
nodes.forEach(n => n.unlink());
}
return parsed;
}

File diff suppressed because one or more lines are too long

View File

@ -1,186 +1,112 @@
import * as tfrpc from '/static/tfrpc.js';
import {html, render} from './lit-all.min.js';
import {styles} from './tf-styles.js';
let g_emojis; let g_emojis;
function get_emojis() { function get_emojis() {
if (g_emojis) { if (g_emojis) {
return Promise.resolve(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(); g_emojis = result.json();
return g_emojis; return g_emojis;
}); });
} }
async function get_recent(author) { export function picker(callback, anchor) {
let recent = await tfrpc.rpc.query( get_emojis().then(function(json) {
` let div = document.createElement('div');
SELECT DISTINCT content ->> '$.vote.expression' AS value div.id = 'emoji_picker';
FROM messages div.style.color = '#000';
WHERE author = ? AND div.style.background = '#fff';
content ->> '$.type' = 'vote' div.style.border = '1px solid #000';
ORDER BY timestamp DESC LIMIT 10 div.style.display = 'block';
`, div.style.position = 'absolute';
[author] div.style.minWidth = 'min(16em, 90vw)';
); div.style.width = 'min(16em, 90vw)';
return recent.map((x) => x.value); div.style.maxWidth = 'min(16em, 90vw)';
} div.style.maxHeight = '16em';
div.style.overflow = 'scroll';
div.style.fontWeight = 'bold';
div.style.fontSize = 'xx-large';
let input = document.createElement('input');
input.type = 'text';
input.style.display = 'block';
input.style.boxSizing = 'border-box';
input.style.width = '100%';
input.style.margin = '0';
input.style.position = 'relative';
div.appendChild(input);
let list = document.createElement('div');
div.appendChild(list);
div.addEventListener('mousedown', function(event) {
event.stopPropagation();
});
export async function picker(callback, anchor, author) { function cleanup() {
let json = await get_emojis(); console.log('emoji cleanup');
let recent = await get_recent(author); div.parentElement.removeChild(div);
window.removeEventListener('keydown', key_down);
console.log('removing click');
document.body.removeEventListener('mousedown', cleanup);
}
let div = document.createElement('div'); function key_down(event) {
div.id = 'emoji_picker'; if (event.key == 'Escape') {
div.style.color = '#000'; cleanup();
div.style.background = '#fff'; }
div.style.border = '1px solid #000'; }
div.style.display = 'flex';
div.style.overflow = 'scroll';
div.style.fontWeight = 'bold';
div.style.fontSize = 'xx-large';
div.style.flex = '1 1';
div.style.flexDirection = 'column';
let input = document.createElement('input');
input.type = 'text';
input.style.display = 'block';
input.style.boxSizing = 'border-box';
input.style.width = '100%';
input.style.margin = '0';
input.style.position = 'relative';
div.appendChild(input);
let list = document.createElement('div');
list.style.overflow = 'scroll';
div.appendChild(list);
div.addEventListener('mousedown', function (event) {
event.stopPropagation();
});
function key_down(event) { function chosen(event) {
if (event.key == 'Escape') { console.log(event.srcElement.innerText);
callback(event.srcElement.innerText);
cleanup(); cleanup();
} }
}
function chosen(event) { function refresh() {
console.log(event.srcElement.innerText); while (list.firstChild) {
callback(event.srcElement.innerText); list.removeChild(list.firstChild);
cleanup(); }
} let search = input.value.toLowerCase();
let any_at_all = false;
function refresh() { for (let row of Object.entries(json)) {
while (list.firstChild) { let header = document.createElement('div');
list.removeChild(list.firstChild); header.appendChild(document.createTextNode(row[0]));
} list.appendChild(header);
let search = input.value.toLowerCase(); let any = false;
let any_at_all = false; for (let entry of Object.entries(row[1])) {
if (recent) { if (search &&
let emoji_to_name = {}; search.length &&
for (let row of Object.values(json)) { entry[0].toLowerCase().indexOf(search) == -1) {
for (let entry of Object.entries(row)) { continue;
emoji_to_name[entry[1]] = entry[0]; }
let emoji = document.createElement('span');
const k_size = '1.25em';
emoji.style.display = 'inline-block';
emoji.style.overflow = 'hidden';
emoji.style.cursor = 'pointer';
emoji.onclick = chosen;
emoji.title = entry[0];
emoji.appendChild(document.createTextNode(entry[1]));
list.appendChild(emoji);
any = true;
any_at_all = true;
}
if (!any) {
list.removeChild(header);
} }
} }
let header = document.createElement('div'); if (!any_at_all) {
header.appendChild(document.createTextNode('Recent')); list.appendChild(document.createTextNode('No matches found.'));
list.appendChild(header);
let any = false;
for (let entry of recent) {
if (
search &&
search.length &&
(emoji_to_name[entry] || '').toLowerCase().indexOf(search) == -1
) {
continue;
}
let emoji = document.createElement('span');
const k_size = '1.25em';
emoji.style.display = 'inline-block';
emoji.style.overflow = 'hidden';
emoji.style.cursor = 'pointer';
emoji.onclick = chosen;
emoji.title = emoji_to_name[entry] || entry;
emoji.appendChild(document.createTextNode(entry));
list.appendChild(emoji);
any = true;
}
if (!any) {
list.removeChild(header);
} }
} }
for (let row of Object.entries(json)) { refresh();
let header = document.createElement('div'); input.oninput = refresh;
header.appendChild(document.createTextNode(row[0])); document.body.appendChild(div);
list.appendChild(header); div.style.position = 'fixed';
let any = false; div.style.top = '50%';
for (let entry of Object.entries(row[1])) { div.style.left = '50%';
if ( div.style.transform = 'translate(-50%, -50%)';
search && input.focus();
search.length && console.log('adding click');
entry[0].toLowerCase().indexOf(search) == -1 document.body.addEventListener('mousedown', cleanup);
) { window.addEventListener('keydown', key_down);
continue; });
}
let emoji = document.createElement('span');
const k_size = '1.25em';
emoji.style.display = 'inline-block';
emoji.style.overflow = 'hidden';
emoji.style.cursor = 'pointer';
emoji.onclick = chosen;
emoji.title = entry[0];
emoji.appendChild(document.createTextNode(entry[1]));
list.appendChild(emoji);
any = true;
any_at_all = true;
}
if (!any) {
list.removeChild(header);
}
}
if (!any_at_all) {
list.appendChild(document.createTextNode('No matches found.'));
}
}
refresh();
input.oninput = refresh;
let parent = document.createElement('div');
function cleanup() {
parent.parentElement.removeChild(parent);
window.removeEventListener('keydown', key_down);
document.body.removeEventListener('mousedown', cleanup);
}
let modal = html`
<style>
${styles}
</style>
<div
class="w3-modal"
style="display: block; box-sizing: border-box; z-index: 10"
>
<div class="w3-modal-content w3-card-4">
<div
class="w3-content w3-theme-d1"
style="display: flex; flex-direction: column; max-height: 50vh"
>
<header class="w3-container" style="flex: 0 0">
<h1>Choose a Reaction</h1>
<span class="w3-button w3-display-topright" @click=${cleanup}
>&times;</span
>
</header>
${div}
<footer class="w3-container w3-padding" style="flex: 0 0">
<button class="w3-button" @click=${cleanup}>Close</button>
</footer>
</div>
</div>
</div>
`;
document.body.appendChild(parent);
render(modal, parent);
input.focus();
document.body.addEventListener('mousedown', cleanup);
window.addEventListener('keydown', key_down);
} }

File diff suppressed because one or more lines are too long

View File

@ -1,23 +1,21 @@
<!doctype html> <!DOCTYPE html>
<html> <html style="color: #fff">
<head> <head>
<title>Tilde Friends</title> <title>Tilde Friends</title>
<base target="_top" /> <base target="_top">
<link rel="stylesheet" href="tribute.css" /> <link rel="stylesheet" href="tribute.css"/>
<style> <style>
.tribute-container { .tribute-container {
color: #000; color: #000;
} }
</style> </style>
</head> </head>
<body style="margin: 0; padding: 0"> <body style="background-color: #223a5e">
<tf-app></tf-app> <tf-app class="w3-deep-purple"/>
<tf-reactions-modal id="reactions_modal"></tf-reactions-modal> <script>window.litDisableBundleWarning = true;</script>
<script>
window.litDisableBundleWarning = true;
</script>
<script src="filesaver.min.js"></script> <script src="filesaver.min.js"></script>
<script src="commonmark.min.js"></script> <script src="commonmark.min.js"></script>
<script src="commonmark-linkify.js" type="module"></script>
<script src="commonmark-hashtag.js" type="module"></script> <script src="commonmark-hashtag.js" type="module"></script>
<script src="script.js" type="module"></script> <script src="script.js" type="module"></script>
</body> </body>

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@ -1,23 +1,17 @@
import {LitElement, html} from './lit-all.min.js'; import {LitElement, html} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js'; import * as tfrpc from '/static/tfrpc.js';
import * as tf_id_picker from './tf-id-picker.js';
import * as tf_app from './tf-app.js'; import * as tf_app from './tf-app.js';
import * as tf_message from './tf-message.js'; import * as tf_message from './tf-message.js';
import * as tf_user from './tf-user.js'; import * as tf_user from './tf-user.js';
import * as tf_compose from './tf-compose.js'; import * as tf_compose from './tf-compose.js';
import * as tf_news from './tf-news.js'; import * as tf_news from './tf-news.js';
import * as tf_profile from './tf-profile.js'; import * as tf_profile from './tf-profile.js';
import * as tf_reactions_modal from './tf-reactions-modal.js'; import * as tf_tab_mentions from './tf-tab-mentions.js';
import * as tf_tab_news from './tf-tab-news.js'; import * as tf_tab_news from './tf-tab-news.js';
import * as tf_tab_news_feed from './tf-tab-news-feed.js'; import * as tf_tab_news_feed from './tf-tab-news-feed.js';
import * as tf_tab_search from './tf-tab-search.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_connections from './tf-tab-connections.js';
import * as tf_tab_query from './tf-tab-query.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';
import * as tf_styles from './tf-styles.js';
window.addEventListener('load', function () {
let style = document.createElement('style');
style.innerText = tf_styles.styles;
document.body.appendChild(style);
});

View File

@ -7,6 +7,7 @@ class TfElement extends LitElement {
return { return {
whoami: {type: String}, whoami: {type: String},
hash: {type: String}, hash: {type: String},
unread: {type: Array},
tab: {type: String}, tab: {type: String},
broadcasts: {type: Array}, broadcasts: {type: Array},
connections: {type: Array}, connections: {type: Array},
@ -15,11 +16,7 @@ class TfElement extends LitElement {
following: {type: Array}, following: {type: Array},
users: {type: Object}, users: {type: Object},
ids: {type: Array}, ids: {type: Array},
channels: {type: Array}, tags: {type: Array},
channels_unread: {type: Object},
channels_latest: {type: Object},
guest: {type: Boolean},
url: {type: String},
}; };
} }
@ -29,24 +26,17 @@ class TfElement extends LitElement {
super(); super();
let self = this; let self = this;
this.hash = '#'; this.hash = '#';
this.unread = [];
this.tab = 'news'; this.tab = 'news';
this.broadcasts = []; this.broadcasts = [];
this.connections = []; this.connections = [];
this.following = []; this.following = [];
this.users = {}; this.users = {};
this.loaded = false; this.loaded = false;
this.channels = []; this.tags = [];
this.channels_unread = {}; tfrpc.rpc.getBroadcasts().then(b => { self.broadcasts = b || []; });
this.channels_latest = {}; tfrpc.rpc.getConnections().then(c => { self.connections = c || []; });
this.loading_latest = 0; tfrpc.rpc.getHash().then(hash => self.set_hash(hash));
this.loading_latest_scheduled = 0;
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) { tfrpc.register(function hashChanged(hash) {
self.set_hash(hash); self.set_hash(hash);
}); });
@ -58,88 +48,26 @@ class TfElement extends LitElement {
self.broadcasts = value; self.broadcasts = value;
} else if (name === 'connections') { } else if (name === 'connections') {
self.connections = value; self.connections = value;
} else if (name === 'identity') {
self.whoami = value;
} }
}); });
this.initial_load(); this.initial_load();
} }
async initial_load() { async initial_load() {
let whoami = await tfrpc.rpc.getActiveIdentity(); let whoami = await tfrpc.rpc.localStorageGet('whoami');
let ids = (await tfrpc.rpc.getIdentities()) || []; let ids = (await tfrpc.rpc.getIdentities()) || [];
this.url = await tfrpc.rpc.url();
this.whoami = whoami ?? (ids.length ? ids[0] : undefined); this.whoami = whoami ?? (ids.length ? ids[0] : undefined);
this.guest = !this.whoami?.length;
this.ids = ids; this.ids = ids;
await this.load_channels();
}
async load_channels() {
let channels = await tfrpc.rpc.query(
`
SELECT
content ->> 'channel' AS channel,
content ->> 'subscribed' AS subscribed
FROM
messages
WHERE
author = ? AND
content ->> 'type' = 'channel'
ORDER BY sequence
`,
[this.whoami]
);
let channel_map = {};
for (let row of channels) {
if (row.subscribed) {
channel_map[row.channel] = true;
} else {
delete channel_map[row.channel];
}
}
this.channels = Object.keys(channel_map).sort();
}
connectedCallback() {
super.connectedCallback();
this._keydown = this.keydown.bind(this);
window.addEventListener('keydown', this._keydown);
}
disconnectedCallback() {
super.disconnectedCallback();
window.removeEventListener('keydown', this._keydown);
}
keydown(event) {
if (event.altKey && event.key == 'ArrowUp') {
this.next_channel(-1);
event.preventDefault();
} else if (event.altKey && event.key == 'ArrowDown') {
this.next_channel(1);
event.preventDefault();
}
}
next_channel(delta) {
let channel_names = ['', '@', '🔐', ...this.channels.map((x) => '#' + x)];
let index = channel_names.indexOf(this.hash.substring(1));
index = index != -1 ? index + delta : 0;
tfrpc.rpc.setHash(
'#' +
encodeURIComponent(
channel_names[(index + channel_names.length) % channel_names.length]
)
);
} }
set_hash(hash) { set_hash(hash) {
this.hash = decodeURIComponent(hash || '#'); this.hash = hash || '#';
if (this.hash.startsWith('#q=')) { if (this.hash.startsWith('#q=')) {
this.tab = 'search'; this.tab = 'search';
} else if (this.hash === '#connections') { } else if (this.hash === '#connections') {
this.tab = 'connections'; this.tab = 'connections';
} else if (this.hash === '#mentions') {
this.tab = 'mentions';
} else if (this.hash.startsWith('#sql=')) { } else if (this.hash.startsWith('#sql=')) {
this.tab = 'query'; this.tab = 'query';
} else { } else {
@ -147,11 +75,9 @@ class TfElement extends LitElement {
} }
} }
async fetch_about(following, users) { async fetch_about(ids, users) {
let ids = Object.keys(following).sort();
const k_cache_version = 1; const k_cache_version = 1;
let cache = await tfrpc.rpc.databaseGet('about'); let cache = await tfrpc.rpc.databaseGet('about');
let original_cache = cache;
cache = cache ? JSON.parse(cache) : {}; cache = cache ? JSON.parse(cache) : {};
if (cache.version !== k_cache_version) { if (cache.version !== k_cache_version) {
cache = { cache = {
@ -160,14 +86,9 @@ class TfElement extends LitElement {
last_row_id: 0, last_row_id: 0,
}; };
} }
let max_row_id = ( let max_row_id = (await tfrpc.rpc.query(`
await tfrpc.rpc.query( SELECT MAX(rowid) AS max_row_id FROM messages
` `, []))[0].max_row_id;
SELECT MAX(rowid) AS max_row_id FROM messages
`,
[]
)
)[0].max_row_id;
for (let id of Object.keys(cache.about)) { for (let id of Object.keys(cache.about)) {
if (ids.indexOf(id) == -1) { if (ids.indexOf(id) == -1) {
delete cache.about[id]; delete cache.about[id];
@ -177,7 +98,7 @@ class TfElement extends LitElement {
let abouts = await tfrpc.rpc.query( let abouts = await tfrpc.rpc.query(
` `
SELECT SELECT
messages.author, json(messages.content) AS content, messages.sequence messages.*
FROM FROM
messages, messages,
json_each(?1) AS following json_each(?1) AS following
@ -188,7 +109,7 @@ class TfElement extends LitElement {
json_extract(messages.content, '$.type') = 'about' json_extract(messages.content, '$.type') = 'about'
UNION UNION
SELECT SELECT
messages.author, json(messages.content) AS content, messages.sequence messages.*
FROM FROM
messages, messages,
json_each(?2) AS following json_each(?2) AS following
@ -199,38 +120,24 @@ class TfElement extends LitElement {
ORDER BY messages.author, messages.sequence ORDER BY messages.author, messages.sequence
`, `,
[ [
JSON.stringify(ids.filter((id) => cache.about[id])), JSON.stringify(ids.filter(id => cache.about[id])),
JSON.stringify(ids.filter((id) => !cache.about[id])), JSON.stringify(ids.filter(id => !cache.about[id])),
cache.last_row_id, cache.last_row_id,
max_row_id, max_row_id,
] ]);
);
for (let about of abouts) { for (let about of abouts) {
let content = JSON.parse(about.content); let content = JSON.parse(about.content);
if (content.about === about.author) { if (content.about === about.author) {
delete content.type; delete content.type;
delete content.about; delete content.about;
cache.about[about.author] = Object.assign( cache.about[about.author] = Object.assign(cache.about[about.author] || {}, content);
cache.about[about.author] || {},
content
);
} }
} }
cache.last_row_id = max_row_id; cache.last_row_id = max_row_id;
let new_cache = JSON.stringify(cache); await tfrpc.rpc.databaseSet('about', JSON.stringify(cache));
if (new_cache !== original_cache) {
let start_time = new Date();
tfrpc.rpc.databaseSet('about', new_cache).then(function () {
console.log('saving about took', (new Date() - start_time) / 1000);
});
}
users = users || {}; users = users || {};
for (let id of Object.keys(cache.about)) { for (let id of Object.keys(cache.about)) {
users[id] = Object.assign( users[id] = Object.assign(users[id] || {}, cache.about[id]);
{follow_depth: following[id]?.d},
users[id] || {},
cache.about[id]
);
} }
return Object.assign({}, users); return Object.assign({}, users);
} }
@ -238,22 +145,19 @@ class TfElement extends LitElement {
async fetch_new_message(id) { async fetch_new_message(id) {
let messages = await tfrpc.rpc.query( let messages = await tfrpc.rpc.query(
` `
SELECT messages.id, previous, author, sequence, timestamp, hash, json(content) AS content, signature SELECT messages.*
FROM messages FROM messages
JOIN json_each(?) AS following ON messages.author = following.value JOIN json_each(?) AS following ON messages.author = following.value
WHERE messages.id = ? WHERE messages.id = ?
`, `,
[JSON.stringify(this.following), id] [
); JSON.stringify(this.following),
for (let message of messages) { id,
if ( ]);
message.author == this.whoami && if (messages && messages.length) {
JSON.parse(message.content)?.type == 'channel' this.unread = [...this.unread, ...messages];
) { this.unread = this.unread.slice(this.unread.length - 1024);
this.load_channels();
}
} }
this.schedule_load_latest();
} }
async _handle_whoami_changed(event) { async _handle_whoami_changed(event) {
@ -268,232 +172,68 @@ class TfElement extends LitElement {
} }
} }
async get_latest_private(following) { async create_identity() {
const k_version = 1; if (confirm("Are you sure you want to create a new identity?")) {
// { "version": 1, "range": [1234, 5678], messages: [ "%1.sha256", "%2.sha256", ... ], latest: rowid } await tfrpc.rpc.createIdentity();
let cache = JSON.parse( this.ids = (await tfrpc.rpc.getIdentities()) || [];
(await tfrpc.rpc.databaseGet(`private:${this.whoami}`)) ?? '{}' if (this.ids && !this.whoami) {
); this.whoami = this.ids[0];
if (cache.version !== k_version) {
cache = {
version: k_version,
messages: [],
range: [],
};
}
let latest = (
await tfrpc.rpc.query('SELECT MAX(rowid) AS latest FROM messages')
)[0].latest;
let ranges = [];
const k_chunk_size = 512;
if (cache.range.length) {
for (let i = cache.range[1]; i < latest; i += k_chunk_size) {
ranges.push([i, Math.min(i + k_chunk_size, latest), true]);
} }
for (let i = cache.range[0]; i >= 0; i -= k_chunk_size) {
ranges.push([
Math.max(i - k_chunk_size, 0),
Math.min(cache.range[0], i + k_chunk_size),
false,
]);
}
} else {
for (let i = 0; i < latest; i += k_chunk_size) {
ranges.push([i, Math.min(i + k_chunk_size, latest), true]);
}
}
for (let range of ranges) {
let messages = await tfrpc.rpc.query(
`
SELECT messages.rowid, messages.id, json(content) AS content
FROM messages
WHERE
messages.rowid > ?1 AND
messages.rowid <= ?2 AND
json(messages.content) LIKE '"%'
ORDER BY sequence DESC
`,
[range[0], range[1]]
);
messages = (await this.decrypt(messages)).filter((x) => x.decrypted);
if (messages.length) {
cache.latest = Math.max(
cache.latest ?? 0,
...messages.map((x) => x.rowid)
);
if (range[2]) {
cache.messages = [...cache.messages, ...messages.map((x) => x.id)];
} else {
cache.messages = [...messages.map((x) => x.id), ...cache.messages];
}
}
cache.range[0] = Math.min(cache.range[0] ?? range[0], range[0]);
cache.range[1] = Math.max(cache.range[1] ?? range[1], range[1]);
await tfrpc.rpc.databaseSet(
`private:${this.whoami}`,
JSON.stringify(cache)
);
}
return cache.latest;
}
async load_channels_latest(following) {
let start_time = new Date();
let latest_private = this.get_latest_private(following);
let channels = await tfrpc.rpc.query(
`
SELECT channels.value AS channel, MAX(messages.rowid) AS rowid FROM messages
JOIN json_each(?1) AS channels ON messages.content ->> 'channel' = channels.value
JOIN json_each(?2) AS following ON messages.author = following.value
WHERE
messages.content ->> 'type' = 'post' AND
messages.content ->> 'root' IS NULL AND
messages.author != ?4
GROUP by channel
UNION
SELECT '' AS channel, MAX(messages.rowid) AS rowid FROM messages
JOIN json_each(?2) AS following ON messages.author = following.value
WHERE
messages.content ->> 'type' = 'post' AND
messages.content ->> 'root' IS NULL AND
messages.author != ?4
UNION
SELECT '@' AS channel, MAX(messages.rowid) AS rowid FROM messages_fts(?3)
JOIN messages ON messages.rowid = messages_fts.rowid
JOIN json_each(?2) AS following ON messages.author = following.value
WHERE messages.author != ?4
`,
[
JSON.stringify(this.channels),
JSON.stringify(following),
'"' + this.whoami.replace('"', '""') + '"',
this.whoami,
]
);
this.channels_latest = Object.fromEntries(
channels.map((x) => [x.channel, x.rowid])
);
console.log('channels took', (new Date() - start_time) / 1000.0);
let self = this;
start_time = new Date();
latest_private.then(function (latest) {
self.channels_latest = Object.assign({}, self.channels_latest, {
'🔐': latest,
});
console.log('private took', (new Date() - start_time) / 1000.0);
});
}
_schedule_load_latest_timer() {
--this.loading_latest_scheduled;
this.schedule_load_latest();
}
schedule_load_latest() {
if (!this.loading_latest) {
this.shadowRoot.getElementById('tf-tab-news')?.load_latest();
this.load();
} else if (!this.loading_latest_scheduled) {
this.loading_latest_scheduled++;
setTimeout(this._schedule_load_latest_timer.bind(this), 5000);
} }
} }
async fetch_user_info(users) { render_id_picker() {
let info = await tfrpc.rpc.query( return html`
` <div style="display: flex; gap: 8px">
SELECT messages.author, MAX(messages.sequence) AS max_seq, MAX(timestamp) AS max_ts FROM messages <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>
JOIN json_each(?) AS following <button class="w3-button w3-dark-grey w3-border" style="flex: 0 0 auto" @click=${this.create_identity} id="create_identity">Create Identity</button>
ON messages.author = following.value </div>
GROUP BY messages.author `;
`, }
[JSON.stringify(Object.keys(users))]
); async load_recent_tags() {
for (let row of info) { let start = new Date();
users[row.author].seq = row.max_seq; this.tags = await tfrpc.rpc.query(`
users[row.author].ts = row.max_ts; WITH
} recent AS (SELECT id, content FROM messages
return users; WHERE messages.timestamp > ? AND json_extract(content, '$.type') = 'post'
ORDER BY timestamp DESC LIMIT 1024),
recent_channels AS (SELECT recent.id, '#' || json_extract(content, '$.channel') AS tag
FROM recent
WHERE json_extract(content, '$.channel') IS NOT NULL),
recent_mentions AS (SELECT recent.id, json_extract(mention.value, '$.link') AS tag
FROM recent, json_each(recent.content, '$.mentions') AS mention
WHERE json_valid(mention.value) AND tag LIKE '#%'),
combined AS (SELECT id, tag FROM recent_channels UNION ALL SELECT id, tag FROM recent_mentions),
by_message AS (SELECT DISTINCT id, tag FROM combined)
SELECT tag, COUNT(*) AS count FROM by_message GROUP BY tag ORDER BY count DESC LIMIT 10
`, [new Date() - 7 * 24 * 60 * 60 * 1000]);
console.log('tags took', (new Date() - start) / 1000.0, 'seconds');
} }
async load() { async load() {
this.loading_latest = true;
try {
let start_time = new Date();
let whoami = this.whoami;
let following = await tfrpc.rpc.following([whoami], 2);
let users = {};
let by_count = [];
for (let [id, v] of Object.entries(following)) {
users[id] = {
following: v.of,
blocking: v.ob,
followed: v.if,
blocked: v.ib,
};
by_count.push({count: v.of, id: id});
}
this.load_channels_latest(Object.keys(following));
this.channels_unread = JSON.parse(
(await tfrpc.rpc.databaseGet('unread')) ?? '{}'
);
this.following = Object.keys(following);
users = await this.fetch_about(following, users);
console.log(
'about took',
(new Date() - start_time) / 1000.0,
'seconds for',
Object.keys(users).length,
'users'
);
start_time = new Date();
users = await this.fetch_user_info(users);
console.log(
'user info took',
(new Date() - start_time) / 1000.0,
'seconds'
);
this.users = users;
console.log(
`load finished ${whoami} => ${this.whoami} in ${(new Date() - start_time) / 1000}`
);
this.whoami = whoami;
this.loaded = whoami;
} finally {
this.loading_latest = false;
}
}
channel_set_unread(event) {
this.channels_unread[event.detail.channel ?? ''] = event.detail.unread;
this.channels_unread = Object.assign({}, this.channels_unread);
tfrpc.rpc.databaseSet('unread', JSON.stringify(this.channels_unread));
}
async decrypt(messages) {
let whoami = this.whoami; let whoami = this.whoami;
return Promise.all( let tags = this.load_recent_tags();
messages.map(async function (message) { let following = await tfrpc.rpc.following([whoami], 2);
let content; let users = {};
try { let by_count = [];
content = JSON.parse(message?.content); for (let [id, v] of Object.entries(following)) {
} catch {} users[id] = {
if (typeof content === 'string') { following: v.of,
let decrypted; blocking: v.ob,
try { followed: v.if,
decrypted = await tfrpc.rpc.try_decrypt(whoami, content); blocked: v.ib,
} catch {} };
if (decrypted) { by_count.push({count: v.of, id: id});
try { }
message.decrypted = JSON.parse(decrypted); console.log(by_count.sort((x, y) => y.count - x.count).slice(0, 20));
} catch { users = await this.fetch_about(Object.keys(following).sort(), users);
message.decrypted = decrypted; this.following = Object.keys(following);
} this.users = users;
} await tags;
} console.log(`load finished ${whoami} => ${this.whoami}`);
return message; this.whoami = whoami;
}) this.loaded = whoami;
);
} }
render_tab() { render_tab() {
@ -501,49 +241,23 @@ class TfElement extends LitElement {
let users = this.users; let users = this.users;
if (this.tab === 'news') { if (this.tab === 'news') {
return html` return html`
<tf-tab-news <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>
id="tf-tab-news"
.following=${this.following}
whoami=${this.whoami}
.users=${this.users}
hash=${this.hash}
?loading=${this.loading}
.channels=${this.channels}
.channels_latest=${this.channels_latest}
.channels_unread=${this.channels_unread}
@channelsetunread=${this.channel_set_unread}
.connections=${this.connections}
></tf-tab-news>
`; `;
} else if (this.tab === 'connections') { } else if (this.tab === 'connections') {
return html` return html`
<tf-tab-connections <tf-tab-connections .users=${this.users} .connections=${this.connections} .broadcasts=${this.broadcasts}></tf-tab-connections>
.users=${this.users} `;
.connections=${this.connections} } else if (this.tab === 'mentions') {
.broadcasts=${this.broadcasts} return html`
></tf-tab-connections> <tf-tab-mentions .following=${this.following} whoami=${this.whoami} .users=${this.users}}></tf-tab-mentions>
`; `;
} else if (this.tab === 'search') { } else if (this.tab === 'search') {
return html` return html`
<tf-tab-search <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>
.following=${this.following}
whoami=${this.whoami}
.users=${this.users}
query=${this.hash?.startsWith('#q=')
? decodeURIComponent(this.hash.substring(3))
: null}
></tf-tab-search>
`; `;
} else if (this.tab === 'query') { } else if (this.tab === 'query') {
return html` return html`
<tf-tab-query <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>
.following=${this.following}
whoami=${this.whoami}
.users=${this.users}
query=${this.hash?.startsWith('#sql=')
? decodeURIComponent(this.hash.substring(5))
: null}
></tf-tab-query>
`; `;
} }
} }
@ -554,21 +268,19 @@ class TfElement extends LitElement {
await tfrpc.rpc.setHash('#'); await tfrpc.rpc.setHash('#');
} else if (tab === 'connections') { } else if (tab === 'connections') {
await tfrpc.rpc.setHash('#connections'); await tfrpc.rpc.setHash('#connections');
} else if (tab === 'mentions') {
await tfrpc.rpc.setHash('#mentions');
} else if (tab === 'query') { } else if (tab === 'query') {
await tfrpc.rpc.setHash('#sql='); await tfrpc.rpc.setHash('#sql=');
} }
} }
refresh() {
tfrpc.rpc.sync();
}
render() { render() {
let self = this; let self = this;
if (!this.loading && this.whoami && this.loaded !== this.whoami) { if (!this.loading && this.whoami && this.loaded !== this.whoami) {
this.loading = true; this.loading = true;
this.load().finally(function () { this.load().finally(function() {
self.loading = false; self.loading = false;
}); });
} }
@ -576,72 +288,29 @@ class TfElement extends LitElement {
const k_tabs = { const k_tabs = {
'📰': 'news', '📰': 'news',
'📡': 'connections', '📡': 'connections',
'@': 'mentions',
'🔍': 'search', '🔍': 'search',
'👩‍💻': 'query', '👩‍💻': 'query',
}; };
let tabs = html` let tabs = html`
<div <div class="w3-bar w3-black">
class="w3-bar w3-theme-l1" ${Object.entries(k_tabs).map(([k, v]) => html`
style="position: static; top: 0; z-index: 10" <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>
> `)}
<button
class=${'w3-bar-item w3-button w3-circle w3-ripple' +
(this.connections?.some((x) => x.flags.one_shot) ? ' w3-spin' : '')}
style="width: 1.5em; height: 1.5em; padding: 8px"
@click=${this.refresh}
>
</button>
${Object.entries(k_tabs).map(
([k, v]) => html`
<button
title=${v}
class="w3-bar-item w3-padding w3-hover-theme tab ${self.tab == v
? 'w3-theme-l2'
: 'w3-theme-l1'}"
@click=${() => self.set_tab(v)}
>
${k}
<span class=${self.tab == v ? '' : 'w3-hide-small'}
>${v.charAt(0).toUpperCase() + v.substring(1)}</span
>
</button>
`
)}
</div> </div>
`; `;
let contents = this.guest let contents =
? html`<div !this.loaded ?
class="w3-display-middle w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge w3-xlarge w3-container" this.loading ?
> html`<div>Loading...</div>` :
<p>🦀 Must be logged in to Tilde Friends to scuttle here. 🦀</p> html`<div>Select or create an identity.</div>` :
<footer class="w3-center"> this.render_tab();
<a
class="w3-button w3-theme-d1"
href=${`/login?return=${encodeURIComponent(this.url)}`}
>Login</a
>
</footer>
</div>`
: !this.loaded || this.loading
? html`<div
class="w3-display-middle w3-panel w3-theme-l5 w3-card-4 w3-padding-large w3-round-xlarge w3-xlarge"
>
<span class="w3-spin" style="display: inline-block">🦀</span>
Loading...
</div>`
: this.render_tab();
return html` return html`
<div ${this.render_id_picker()}
style="width: 100vw; min-height: 100vh; height: 100vh; display: flex; flex-direction: column" ${tabs}
class="w3-theme-dark" ${this.tags.map(x => html`<tf-tag tag=${x.tag} count=${x.count}></tf-tag>`)}
> ${contents}
<div style="flex: 0 0">${tabs}</div>
<div style="flex: 1 1; overflow: auto; contain: layout">
${contents}
</div>
</div>
`; `;
} }
} }

View File

@ -1,4 +1,4 @@
import {LitElement, html, unsafeHTML, live} from './lit-all.min.js'; import {LitElement, html, unsafeHTML} from './lit-all.min.js';
import * as tfutils from './tf-utils.js'; import * as tfutils from './tf-utils.js';
import * as tfrpc from '/static/tfrpc.js'; import * as tfrpc from '/static/tfrpc.js';
import {styles} from './tf-styles.js'; import {styles} from './tf-styles.js';
@ -13,9 +13,6 @@ class TfComposeElement extends LitElement {
branch: {type: String}, branch: {type: String},
apps: {type: Object}, apps: {type: Object},
drafts: {type: Object}, drafts: {type: Object},
author: {type: String},
channel: {type: String},
new_thread: {type: Boolean},
}; };
} }
@ -28,8 +25,6 @@ class TfComposeElement extends LitElement {
this.branch = undefined; this.branch = undefined;
this.apps = undefined; this.apps = undefined;
this.drafts = {}; this.drafts = {};
this.author = undefined;
this.new_thread = false;
} }
process_text(text) { process_text(text) {
@ -63,13 +58,11 @@ class TfComposeElement extends LitElement {
link: link, link: link,
}; };
} }
draft.mentions[link].name = name.startsWith('@') draft.mentions[link].name = name.startsWith('@') ? name.substring(1) : name;
? name.substring(1)
: name;
updated = true; updated = true;
} }
if (updated) { if (updated) {
setTimeout(() => this.notify(draft), 0); this.requestUpdate();
} }
return tfutils.markdown(text); return tfutils.markdown(text);
} }
@ -77,31 +70,36 @@ class TfComposeElement extends LitElement {
input(event) { input(event) {
let edit = this.renderRoot.getElementById('edit'); let edit = this.renderRoot.getElementById('edit');
let preview = this.renderRoot.getElementById('preview'); let preview = this.renderRoot.getElementById('preview');
preview.innerHTML = this.process_text(edit.innerText); preview.innerHTML = this.process_text(edit.value);
let content_warning = this.renderRoot.getElementById('content_warning'); let content_warning = this.renderRoot.getElementById('content_warning');
let draft = this.get_draft(); let content_warning_preview = this.renderRoot.getElementById('content_warning_preview');
draft.text = edit.innerText; if (content_warning && content_warning_preview) {
draft.content_warning = content_warning?.value; content_warning_preview.innerText = content_warning.value;
setTimeout(() => this.notify(draft), 0); }
} }
notify(draft) { notify(draft) {
this.dispatchEvent( this.dispatchEvent(new CustomEvent('tf-draft', {
new CustomEvent('tf-draft', { bubbles: true,
bubbles: true, composed: true,
composed: true, detail: {
detail: { id: this.branch,
id: this.branch, draft: draft
draft: draft, },
}, }));
}) }
);
change() {
let draft = this.get_draft();
draft.text = this.renderRoot.getElementById('edit')?.value;
draft.content_warning = this.renderRoot.getElementById('content_warning')?.value;
this.notify(draft);
} }
convert_to_format(buffer, type, mime_type) { convert_to_format(buffer, type, mime_type) {
return new Promise(function (resolve, reject) { return new Promise(function(resolve, reject) {
let img = new Image(); let img = new Image();
img.onload = function () { img.onload = function() {
let canvas = document.createElement('canvas'); let canvas = document.createElement('canvas');
let width_scale = Math.min(img.width, 1024) / img.width; let width_scale = Math.min(img.width, 1024) / img.width;
let height_scale = Math.min(img.height, 1024) / img.height; let height_scale = Math.min(img.height, 1024) / img.height;
@ -111,17 +109,13 @@ class TfComposeElement extends LitElement {
let context = canvas.getContext('2d'); let context = canvas.getContext('2d');
context.drawImage(img, 0, 0, canvas.width, canvas.height); context.drawImage(img, 0, 0, canvas.width, canvas.height);
let data_url = canvas.toDataURL(mime_type); let data_url = canvas.toDataURL(mime_type);
let result = atob(data_url.split(',')[1]) let result = atob(data_url.split(',')[1]).split('').map(x => x.charCodeAt(0));
.split('')
.map((x) => x.charCodeAt(0));
resolve(result); resolve(result);
}; };
img.onerror = function (event) { img.onerror = function(event) {
reject(new Error('Failed to load image.')); reject(new Error('Failed to load image.'));
}; };
let raw = Array.from(new Uint8Array(buffer)) let raw = Array.from(new Uint8Array(buffer)).map(b => String.fromCharCode(b)).join('');
.map((b) => String.fromCharCode(b))
.join('');
let original = `data:${type};base64,${btoa(raw)}`; let original = `data:${type};base64,${btoa(raw)}`;
img.src = original; img.src = original;
}); });
@ -137,11 +131,7 @@ class TfComposeElement extends LitElement {
let best_buffer; let best_buffer;
let best_type; let best_type;
for (let format of ['image/png', 'image/jpeg', 'image/webp']) { for (let format of ['image/png', 'image/jpeg', 'image/webp']) {
let test_buffer = await self.convert_to_format( let test_buffer = await self.convert_to_format(buffer, file.type, format);
buffer,
file.type,
format
);
if (!best_buffer || test_buffer.length < best_buffer.length) { if (!best_buffer || test_buffer.length < best_buffer.length) {
best_buffer = test_buffer; best_buffer = test_buffer;
best_type = format; best_type = format;
@ -164,9 +154,10 @@ class TfComposeElement extends LitElement {
size: buffer.length ?? buffer.byteLength, size: buffer.length ?? buffer.byteLength,
}; };
let edit = self.renderRoot.getElementById('edit'); let edit = self.renderRoot.getElementById('edit');
edit.innerText += `\n![${name}](${id})`; edit.value += `\n![${name}](${id})`;
self.change();
self.input(); self.input();
} catch (e) { } catch(e) {
alert(e?.message); alert(e?.message);
} }
} }
@ -183,13 +174,6 @@ class TfComposeElement extends LitElement {
break; break;
} }
} }
event.preventDefault();
document.execCommand(
'insertText',
false,
event.clipboardData.getData('text/plain')
);
} }
async submit() { async submit() {
@ -198,27 +182,12 @@ class TfComposeElement extends LitElement {
let edit = this.renderRoot.getElementById('edit'); let edit = this.renderRoot.getElementById('edit');
let message = { let message = {
type: 'post', type: 'post',
text: edit.innerText, text: edit.value,
channel: this.channel,
}; };
if (this.root || this.branch) { if (this.root || this.branch) {
message.root = this.new_thread ? (this.branch ?? this.root) : this.root; message.root = this.root;
message.branch = this.branch; message.branch = this.branch;
} }
let reply = Object.fromEntries(
(
await tfrpc.rpc.query(
`
SELECT messages.id, messages.author FROM messages
JOIN json_each(?) AS refs ON messages.id = refs.value
`,
[JSON.stringify([this.root, this.branch])]
)
).map((row) => [row.id, row.author])
);
if (Object.keys(reply).length) {
message.reply = reply;
}
if (Object.values(draft.mentions || {}).length) { if (Object.values(draft.mentions || {}).length) {
message.mentions = Object.values(draft.mentions); message.mentions = Object.values(draft.mentions);
} }
@ -232,30 +201,36 @@ class TfComposeElement extends LitElement {
to = [...to]; to = [...to];
message.recps = to; message.recps = to;
console.log('message is now', message); console.log('message is now', message);
message = await tfrpc.rpc.encrypt( message = await tfrpc.rpc.encrypt(this.whoami, to, JSON.stringify(message));
this.whoami,
to,
JSON.stringify(message)
);
console.log('encrypted as', message); console.log('encrypted as', message);
} }
try { try {
await tfrpc.rpc.appendMessage(this.whoami, message); await tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
self.notify(undefined); edit.value = '';
self.change();
self.notify(undefined);
self.requestUpdate();
});
} catch (error) { } catch (error) {
alert(error.message); alert(error.message);
} }
} }
discard() { discard() {
let edit = this.renderRoot.getElementById('edit');
edit.value = '';
this.change();
let preview = this.renderRoot.getElementById('preview');
preview.innerHTML = '';
this.notify(undefined); this.notify(undefined);
} }
attach() { attach() {
let self = this; let self = this;
let edit = this.renderRoot.getElementById('edit');
let input = document.createElement('input'); let input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.onchange = function (event) { input.onchange = function(event) {
let file = event.target.files[0]; let file = event.target.files[0];
self.add_file(file); self.add_file(file);
}; };
@ -266,15 +241,12 @@ class TfComposeElement extends LitElement {
this.last_autocomplete = text; this.last_autocomplete = text;
let results = []; let results = [];
try { try {
let rows = await tfrpc.rpc.query( let rows = await tfrpc.rpc.query(`
` SELECT messages.content FROM messages_fts(?)
SELECT json(messages.content) AS content FROM messages_fts(?)
JOIN messages ON messages.rowid = messages_fts.rowid JOIN messages ON messages.rowid = messages_fts.rowid
WHERE json(messages.content) LIKE ? WHERE messages.content LIKE ?
ORDER BY timestamp DESC LIMIT 10 ORDER BY timestamp DESC LIMIT 10
`, `, ['"' + text.replace('"', '""') + '"', `%![%${text}%](%)%`]);
['"' + text.replace('"', '""') + '"', `%![%${text}%](%)%`]
);
for (let row of rows) { for (let row of rows) {
for (let match of row.content.matchAll(/!\[([^\]]*)\]\((&.*?)\)/g)) { for (let match of row.content.matchAll(/!\[([^\]]*)\]\((&.*?)\)/g)) {
if (match[1].toLowerCase().indexOf(text.toLowerCase()) != -1) { if (match[1].toLowerCase().indexOf(text.toLowerCase()) != -1) {
@ -290,39 +262,19 @@ class TfComposeElement extends LitElement {
} }
firstUpdated() { firstUpdated() {
let values = Object.entries(this.users).map((x) => ({
key: x[1].name ?? x[0],
value: x[0],
}));
if (this.author) {
values = [].concat(
[
{
key: this.users[this.author]?.name,
value: this.author,
},
],
values
);
}
let tribute = new Tribute({ let tribute = new Tribute({
iframe: this.shadowRoot,
collection: [ collection: [
{ {
values: values, values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
selectTemplate: function (item) { selectTemplate: function(item) {
return item return `[@${item.original.key}](${item.original.value})`;
? `[@${item.original.key}](${item.original.value})`
: undefined;
}, },
}, },
{ {
trigger: '&', trigger: '&',
values: this.autocomplete, values: this.autocomplete,
selectTemplate: function (item) { selectTemplate: function(item) {
return item return `![${item.original.key}](${item.original.value})`;
? `![${item.original.key}](${item.original.value})`
: undefined;
}, },
}, },
], ],
@ -333,20 +285,16 @@ class TfComposeElement extends LitElement {
updated() { updated() {
super.updated(); super.updated();
let edit = this.renderRoot.getElementById('edit'); let edit = this.renderRoot.getElementById('edit');
if (this.last_updated_text !== edit.innerText) { if (this.last_updated_text !== edit.value) {
let preview = this.renderRoot.getElementById('preview'); let preview = this.renderRoot.getElementById('preview');
preview.innerHTML = this.process_text(edit.innerText); preview.innerHTML = this.process_text(edit.value);
this.last_updated_text = edit.innerText; this.last_updated_text = edit.value;
} }
let encrypt = this.renderRoot.getElementById('encrypt_to'); let encrypt = this.renderRoot.getElementById('encrypt_to');
if (encrypt) { if (encrypt) {
let tribute = new Tribute({ let tribute = new Tribute({
iframe: this.shadowRoot, values: Object.entries(this.users).map(x => ({key: x[1].name, value: x[0]})),
values: Object.entries(this.users).map((x) => ({ selectTemplate: function(item) {
key: x[1].name,
value: x[0],
})),
selectTemplate: function (item) {
return item.original.value; return item.original.value;
}, },
}); });
@ -357,35 +305,26 @@ class TfComposeElement extends LitElement {
remove_mention(id) { remove_mention(id) {
let draft = this.get_draft(); let draft = this.get_draft();
delete draft.mentions[id]; delete draft.mentions[id];
setTimeout(() => this.notify(), 0); this.notify(draft);
this.requestUpdate();
} }
render_mention(mention) { render_mention(mention) {
let self = this; let self = this;
return html` <div style="display: flex; flex-direction: row"> return html`
<div style="align-self: center; margin: 0.5em"> <div style="display: flex; flex-direction: row">
<button <div style="align-self: center; margin: 0.5em">
class="w3-button w3-theme-d1" <button class="w3-button w3-dark-grey" title="Remove ${mention.name} mention" @click=${() => self.remove_mention(mention.link)}>🚮</button>
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>
</div> <div style="display: flex; flex-direction: column">
</div>`; <h3>${mention.name}</h3>
<div style="padding-left: 1em">
${Object.entries(mention)
.filter(x => x[0] != 'name')
.map(x => html`<div><span style="font-weight: bold">${x[0]}</span>: ${x[1]}</div>`)}
</div>
</div>
</div>`;
} }
render_attach_app() { render_attach_app() {
@ -419,22 +358,13 @@ class TfComposeElement extends LitElement {
if (this.apps) { if (this.apps) {
return html` return html`
<div class="w3-card-4 w3-margin w3-padding"> <div class="w3-card-4 w3-margin w3-padding">
<select id="select" class="w3-select w3-theme-d1"> <select id="select" class="w3-select w3-dark-grey">
${Object.keys(self.apps).map( ${Object.keys(self.apps).map(app => html`<option value=${app}>${app}</option>`)}
(app) => html`<option value=${app}>${app}</option>`
)}
</select> </select>
<button class="w3-button w3-theme-d1" @click=${attach_selected_app}> <button class="w3-button w3-dark-grey" @click=${attach_selected_app}>Attach</button>
Attach <button class="w3-button w3-dark-grey" @click=${() => this.apps = null}>Cancel</button>
</button>
<button
class="w3-button w3-theme-d1"
@click=${() => (this.apps = null)}
>
Cancel
</button>
</div> </div>
`; `;
} }
} }
@ -444,16 +374,9 @@ class TfComposeElement extends LitElement {
self.apps = await tfrpc.rpc.apps(); self.apps = await tfrpc.rpc.apps();
} }
if (!this.apps) { if (!this.apps) {
return html`<button class="w3-button w3-theme-d1" @click=${attach_app}> return html`<button class="w3-button w3-dark-grey" @click=${attach_app}>Attach App</button>`;
Attach App
</button>`;
} else { } else {
return html`<button return html`<button class="w3-button w3-dark-grey" @click=${() => this.apps = null}>Discard App</button>`;
class="w3-button w3-theme-d1"
@click=${() => (this.apps = null)}
>
Discard App
</button>`;
} }
} }
@ -471,34 +394,20 @@ class TfComposeElement extends LitElement {
return html` return html`
<div class="w3-container w3-padding"> <div class="w3-container w3-padding">
<p> <p>
<input type="checkbox" class="w3-check w3-theme-d1" id="cw" @change=${() => self.set_content_warning(undefined)} checked="checked"></input> <input type="checkbox" class="w3-check w3-dark-grey" id="cw" @change=${() => self.set_content_warning(undefined)} checked="checked"></input>
<label for="cw">CW</label> <label for="cw">CW</label>
</p> </p>
<input type="text" class="w3-input w3-border w3-theme-d1" id="content_warning" placeholder="Enter a content warning here." @input=${self.input} value=${draft.content_warning}></input> <input type="text" class="w3-input w3-border w3-dark-grey" id="content_warning" placeholder="Enter a content warning here." @input=${this.input} @change=${this.change} value=${draft.content_warning}></input>
</div> </div>
`; `;
} else { } else {
return html` return html`
<input type="checkbox" class="w3-check w3-theme-d1" id="cw" @change=${() => self.set_content_warning('')}></input> <input type="checkbox" class="w3-check w3-dark-grey" id="cw" @change=${() => self.set_content_warning('')}></input>
<label for="cw">CW</label> <label for="cw">CW</label>
`; `;
} }
} }
render_new_thread() {
let self = this;
if (
this.root !== undefined &&
this.branch !== undefined &&
this.root != this.branch
) {
return html`
<input type="checkbox" class="w3-check w3-theme-d1" id="new_thread" @change=${() => (self.new_thread = !self.new_thread)} ?checked=${self.new_thread}></input>
<label for="new_thread">New Thread</label>
`;
}
}
get_draft() { get_draft() {
return this.drafts[this.branch || ''] || {}; return this.drafts[this.branch || ''] || {};
} }
@ -523,16 +432,14 @@ class TfComposeElement extends LitElement {
<div style="display: flex; flex-direction: row; width: 100%"> <div style="display: flex; flex-direction: row; width: 100%">
<label for="encrypt_to">🔐 To:</label> <label for="encrypt_to">🔐 To:</label>
<input type="text" id="encrypt_to" style="display: flex; flex: 1 1" @input=${this.update_encrypt}></input> <input type="text" id="encrypt_to" style="display: flex; flex: 1 1" @input=${this.update_encrypt}></input>
<button class="w3-button w3-theme-d1" @click=${() => this.set_encrypt(undefined)}>🚮</button> <button class="w3-button w3-dark-grey" @click=${() => this.set_encrypt(undefined)}>🚮</button>
</div> </div>
<ul> <ul>
${draft.encrypt_to.map( ${draft.encrypt_to.map(x => html`
(x) => html`
<li> <li>
<tf-user id=${x} .users=${this.users}></tf-user> <tf-user id=${x} .users=${this.users}></tf-user>
<input type="button" class="w3-button w3-theme-d1" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter((id) => id != x))}></input> <input type="button" class="w3-button w3-dark-grey" value="🚮" @click=${() => this.set_encrypt(draft.encrypt_to.filter(id => id != x))}></input>
</li>` </li>`)}
)}
</ul> </ul>
`; `;
} }
@ -548,78 +455,34 @@ class TfComposeElement extends LitElement {
let self = this; let self = this;
let draft = self.get_draft(); let draft = self.get_draft();
let content_warning = let content_warning =
draft.content_warning !== undefined draft.content_warning !== undefined ?
? html`<div class="w3-panel w3-round-xlarge w3-theme-d2"> html`<div class="w3-panel w3-round-xlarge w3-blue">
<p id="content_warning_preview">${draft.content_warning}</p> <p id="content_warning_preview">${draft.content_warning}</p>
</div>` </div>` :
: undefined; undefined;
let encrypt = let encrypt = draft.encrypt_to !== undefined ?
draft.encrypt_to !== undefined undefined :
? undefined html`<button class="w3-button w3-dark-grey" @click=${() => this.set_encrypt([])}>🔐</button>`;
: html`<button
class="w3-button w3-theme-d1"
@click=${() => this.set_encrypt([])}
>
🔐
</button>`;
let result = html` let result = html`
<style> <div class="w3-card-4 w3-blue-grey w3-padding" style="box-sizing: border-box">
.w3-input:empty::before { ${this.render_encrypt()}
content: attr(placeholder); <div style="display: flex; flex-direction: row; width: 100%; gap: 4px">
} <div style="flex: 1 0 50%">
.w3-input:empty:focus::before { <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>
content: '';
}
</style>
<div
class="w3-card-4 w3-theme-d4 w3-padding w3-margin-top w3-margin-bottom"
style="box-sizing: border-box"
>
<header class="w3-container">
${this.channel !== undefined
? html`<p>To #${this.channel}:</p>`
: undefined}
${this.render_encrypt()}
</header>
<div class="w3-container w3-padding-small">
<div class="w3-half">
<span
class="w3-input w3-theme-d1 w3-border"
style="resize: vertical; width: 100%; overflow: hidden; white-space: pre-wrap"
placeholder="Write a post here."
id="edit"
@input=${this.input}
@paste=${this.paste}
contenteditable="plaintext-only"
.innerText=${live(draft.text ?? '')}
></span>
</div> </div>
<div class="w3-half w3-container"> <div style="flex: 1 0 50%">
${content_warning} ${content_warning}
<p id="preview"></p> <div id="preview"></div>
</div> </div>
</div> </div>
${Object.values(draft.mentions || {}).map((x) => ${Object.values(draft.mentions || {}).map(x => self.render_mention(x))}
self.render_mention(x) ${this.render_attach_app()}
)} ${this.render_content_warning()}
<footer class="w3-container"> <button class="w3-button w3-dark-grey" id="submit" @click=${this.submit}>Submit</button>
${this.render_attach_app()} ${this.render_content_warning()} <button class="w3-button w3-dark-grey" @click=${this.attach}>Attach</button>
${this.render_new_thread()} ${this.render_attach_app_button()}
<button ${encrypt}
class="w3-button w3-theme-d1" <button class="w3-button w3-dark-grey" @click=${this.discard}>Discard</button>
id="submit"
@click=${this.submit}
>
Submit
</button>
<button class="w3-button w3-theme-d1" @click=${this.attach}>
Attach
</button>
${this.render_attach_app_button()} ${encrypt}
<button class="w3-button w3-theme-d1" @click=${this.discard}>
Discard
</button>
</footer>
</div> </div>
`; `;
return result; return result;

41
apps/ssb/tf-id-picker.js Normal file
View File

@ -0,0 +1,41 @@
import {LitElement, html} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
import {styles} from './tf-styles.js';
/*
** Provide a list of IDs, and this lets the user pick one.
*/
class TfIdentityPickerElement extends LitElement {
static get properties() {
return {
ids: {type: Array},
selected: {type: String},
users: {type: Object},
};
}
static styles = styles;
constructor() {
super();
this.ids = [];
this.users = {};
}
changed(event) {
this.selected = event.srcElement.value;
this.dispatchEvent(new Event('change', {
srcElement: this,
}));
}
render() {
return html`
<select class="w3-select w3-dark-grey w3-padding w3-border" @change=${this.changed} style="max-width: 100%; overflow: hidden">
${(this.ids ?? []).map(id => html`<option ?selected=${id == this.selected} value=${id}>${this.users[id]?.name ? (this.users[id]?.name + ' - ') : undefined}<small>${id}</small></option>`)}
</select>
`;
}
}
customElements.define('tf-id-picker', TfIdentityPickerElement);

View File

@ -1,4 +1,4 @@
import {LitElement, html, repeat, render, unsafeHTML} from './lit-all.min.js'; import {LitElement, html, unsafeHTML} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js'; import * as tfrpc from '/static/tfrpc.js';
import * as tfutils from './tf-utils.js'; import * as tfutils from './tf-utils.js';
import * as emojis from './emojis.js'; import * as emojis from './emojis.js';
@ -14,8 +14,6 @@ class TfMessageElement extends LitElement {
format: {type: String}, format: {type: String},
blog_data: {type: String}, blog_data: {type: String},
expanded: {type: Object}, expanded: {type: Object},
channel: {type: String},
channel_unread: {type: Number},
}; };
} }
@ -30,37 +28,17 @@ class TfMessageElement extends LitElement {
this.drafts = {}; this.drafts = {};
this.format = 'message'; this.format = 'message';
this.expanded = {}; this.expanded = {};
this.channel_unread = -1;
} }
show_reply() { show_reply() {
let event = new CustomEvent('tf-draft', { let event = new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.message?.id, draft: {
bubbles: true, encrypt_to: this.message?.decrypted?.recps,
composed: true, }}});
detail: {
id: this.message?.id,
draft: {
encrypt_to: this.message?.decrypted?.recps,
},
},
});
this.dispatchEvent(event); this.dispatchEvent(event);
} }
discard_reply() { discard_reply() {
this.dispatchEvent( this.dispatchEvent(new CustomEvent('tf-draft', {bubbles: true, composed: true, detail: {id: this.id, draft: undefined}}));
new CustomEvent('tf-draft', {
bubbles: true,
composed: true,
detail: {id: this.id, draft: undefined},
})
);
}
show_reactions() {
let modal = document.getElementById('reactions_modal');
modal.users = this.users;
modal.votes = this.message?.votes || [];
} }
render_votes() { render_votes() {
@ -75,33 +53,12 @@ class TfMessageElement extends LitElement {
return expression; return expression;
} }
} }
if (this.message?.votes?.length) { return html`<div>${(this.message.votes || []).map(
return html` <div class="w3-container"> vote => html`
<div <span title="${this.users[vote.author]?.name ?? vote.author} ${new Date(vote.timestamp)}">
class="w3-button w3-bar w3-padding-small" ${normalize_expression(vote.content.vote.expression)}
@click=${this.show_reactions} </span>
> `)}</div>`;
${(this.message.votes || []).map(
(vote) => html`
<span
class="w3-bar-item w3-padding-small"
title="${this.users[vote.author]?.name ??
vote.author} ${new Date(vote.timestamp)}"
>
${normalize_expression(vote.content.vote.expression)}
</span>
`
)}
</div>
</div>`;
}
}
render_json(value) {
let json = JSON.stringify(value, null, 2);
return html`
<pre style="white-space: pre-wrap; overflow-wrap: anywhere">${json}</pre>
`;
} }
render_raw() { render_raw() {
@ -115,28 +72,30 @@ class TfMessageElement extends LitElement {
content: this.message?.content, content: this.message?.content,
signature: this.message?.signature, signature: this.message?.signature,
}; };
return this.render_json(raw); return html`<div style="white-space: pre-wrap">${JSON.stringify(raw, null, 2)}</div>`;
} }
vote(emoji) { vote(emoji) {
let reaction = emoji; let reaction = emoji;
let message = this.message.id; let message = this.message.id;
tfrpc.rpc if (confirm('Are you sure you want to react with ' + reaction + ' to ' + message + '?')) {
.appendMessage(this.whoami, { tfrpc.rpc.appendMessage(
type: 'vote', this.whoami,
vote: { {
link: message, type: 'vote',
value: 1, vote: {
expression: reaction, link: message,
}, value: 1,
}) expression: reaction,
.catch(function (error) { },
alert(error?.message); }).catch(function(error) {
}); alert(error?.message);
});
}
} }
react(event) { react(event) {
emojis.picker((x) => this.vote(x), null, this.whoami); emojis.picker(x => this.vote(x));
} }
show_image(link) { show_image(link) {
@ -170,12 +129,9 @@ class TfMessageElement extends LitElement {
body_click(event) { body_click(event) {
if (event.srcElement.tagName == 'IMG') { if (event.srcElement.tagName == 'IMG') {
this.show_image(event.srcElement.src); this.show_image(event.srcElement.src);
} else if ( } else if (event.srcElement.tagName == 'DIV' && event.srcElement.classList.contains('img_caption')) {
event.srcElement.tagName == 'DIV' &&
event.srcElement.classList.contains('img_caption')
) {
let next = event.srcElement.nextSibling; let next = event.srcElement.nextSibling;
if (next.style.display != 'none') { if (next.style.display == 'block') {
next.style.display = 'none'; next.style.display = 'none';
} else { } else {
next.style.display = 'block'; next.style.display = 'block';
@ -184,76 +140,50 @@ class TfMessageElement extends LitElement {
} }
render_mention(mention) { render_mention(mention) {
if (!mention?.link || typeof mention.link != 'string') { if (!mention?.link || typeof(mention.link) != 'string') {
return this.render_json(mention); return html` <pre>${JSON.stringify(mention)}</pre>`;
} else if ( } else if (mention?.link?.startsWith('&') &&
mention?.link?.startsWith('&') && mention?.type?.startsWith('image/')) {
mention?.type?.startsWith('image/')
) {
return html` return html`
<img <img src=${'/' + mention.link + '/view'} style="max-width: 128px; max-height: 128px" title=${mention.name} @click=${() => this.show_image('/' + mention.link + '/view')}>
src=${'/' + mention.link + '/view'}
style="max-width: 128px; max-height: 128px"
title=${mention.name}
@click=${() => this.show_image('/' + mention.link + '/view')}
/>
`; `;
} else if ( } else if (mention.link?.startsWith('&') &&
mention.link?.startsWith('&') && mention.name?.startsWith('audio:')) {
mention.name?.startsWith('audio:')
) {
return html` return html`
<audio controls style="height: 32px"> <audio controls style="height: 32px">
<source src=${'/' + mention.link + '/view'}></source> <source src=${'/' + mention.link + '/view'}></source>
</audio> </audio>
`; `;
} else if ( } else if (mention.link?.startsWith('&') &&
mention.link?.startsWith('&') && mention.name?.startsWith('video:')) {
mention.name?.startsWith('video:')
) {
return html` return html`
<video controls style="max-height: 240px; max-width: 128px"> <video controls style="max-height: 240px; max-width: 128px">
<source src=${'/' + mention.link + '/view'}></source> <source src=${'/' + mention.link + '/view'}></source>
</video> </video>
`; `;
} else if ( } else if (mention.link?.startsWith('&') &&
mention.link?.startsWith('&') && mention?.type === 'application/tildefriends') {
mention?.type === 'application/tildefriends'
) {
return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`; return html` <a href=${`/${mention.link}/`}>😎 ${mention.name}</a>`;
} else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) { } else if (mention.link?.startsWith('%') || mention.link?.startsWith('@')) {
return html` <a href=${'#' + encodeURIComponent(mention.link)} return html` <a href=${'#' + encodeURIComponent(mention.link)}>${mention.name}</a>`;
>${mention.name}</a
>`;
} else if (mention.link?.startsWith('#')) { } else if (mention.link?.startsWith('#')) {
return html` <a href=${'#' + encodeURIComponent('#' + mention.link)} return html` <a href=${'#q=' + encodeURIComponent(mention.link)}>${mention.link}</a>`;
>${mention.link}</a } else if (Object.keys(mention).length == 2 && mention.link && mention.name) {
>`;
} else if (
Object.keys(mention).length == 2 &&
mention.link &&
mention.name
) {
return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`; return html` <a href=${`/${mention.link}/view`}>${mention.name}</a>`;
} else { } else {
return this.render_json(mention); return html` <pre style="white-space: pre-wrap">${JSON.stringify(mention, null, 2)}</pre>`;
} }
} }
render_mentions() { render_mentions() {
let mentions = this.message?.content?.mentions || []; let mentions = this.message?.content?.mentions || [];
mentions = mentions.filter( mentions = mentions.filter(x => this.message?.content?.text?.indexOf(x.link) === -1);
(x) =>
this.message?.content?.text?.indexOf(
typeof x === 'string' ? x : x.link
) === -1
);
if (mentions.length) { if (mentions.length) {
let self = this; let self = this;
return html` return html`
<fieldset style="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> <legend>Mentions</legend>
${mentions.map((x) => self.render_mention(x))} ${mentions.map(x => self.render_mention(x))}
</fieldset> </fieldset>
`; `;
} }
@ -264,78 +194,32 @@ class TfMessageElement extends LitElement {
return 0; return 0;
} }
let total = message.child_messages.length; let total = message.child_messages.length;
for (let m of message.child_messages) { for (let m of message.child_messages)
{
total += this.total_child_messages(m); total += this.total_child_messages(m);
} }
return total; return total;
} }
set_expanded(expanded, tag) { set_expanded(expanded, tag) {
this.dispatchEvent( this.dispatchEvent(new CustomEvent('tf-expand', {bubbles: true, composed: true, detail: {id: (this.message.id || '') + (tag || ''), expanded: expanded}}));
new CustomEvent('tf-expand', {
bubbles: true,
composed: true,
detail: {id: (this.message.id || '') + (tag || ''), expanded: expanded},
})
);
} }
toggle_expanded(tag) { toggle_expanded(tag) {
this.set_expanded( this.set_expanded(!this.expanded[(this.message.id || '') + (tag || '')], tag);
!this.expanded[(this.message.id || '') + (tag || '')],
tag
);
} }
render_children() { render_children() {
let self = this; let self = this;
if (this.message.child_messages?.length) { if (this.message.child_messages?.length) {
if (!this.expanded[this.message.id]) { if (!this.expanded[this.message.id]) {
return html`<button return html`<button class="w3-button w3-dark-grey" @click=${() => self.set_expanded(true)}>+ ${this.total_child_messages(this.message) + ' More'}</button>`;
class="w3-button w3-theme-d1"
@click=${() => self.set_expanded(true)}
>
+ ${this.total_child_messages(this.message) + ' More'}
</button>`;
} else { } else {
return html`<button 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>`)}`;
class="w3-button w3-theme-d1"
@click=${() => self.set_expanded(false)}
>
Collapse</button
>${repeat(
this.message.child_messages || [],
(x) => x.id,
(x) =>
html`<tf-message
.message=${x}
whoami=${this.whoami}
.users=${this.users}
.drafts=${this.drafts}
.expanded=${this.expanded}
channel=${this.channel}
channel_unread=${this.channel_unread}
></tf-message>`
)}`;
} }
} else {
return undefined;
} }
} }
mark_unread() {
this.dispatchEvent(
new CustomEvent('channelsetunread', {
bubbles: true,
composed: true,
detail: {
channel: this.channel,
unread: this.message.rowid,
},
})
);
}
render_channels() { render_channels() {
let content = this.message?.content; let content = this.message?.content;
if (this?.messsage?.decrypted?.type == 'post') { if (this?.messsage?.decrypted?.type == 'post') {
@ -347,189 +231,13 @@ class TfMessageElement extends LitElement {
} }
if (Array.isArray(content.mentions)) { if (Array.isArray(content.mentions)) {
for (let mention of content.mentions) { for (let mention of content.mentions) {
if (typeof mention?.link === 'string' && mention.link.startsWith('#')) { if (typeof mention?.link === 'string' &&
mention.link.startsWith('#')) {
channels.push(mention.link); channels.push(mention.link);
} }
} }
} }
return channels.map((x) => html`<tf-tag tag=${x}></tf-tag>`); return channels.map(x => html`<tf-tag tag=${x}></tf-tag>`);
}
class_background() {
return this.message?.decrypted
? 'w3-pale-red'
: this.message?.rowid >= this.channel_unread
? 'w3-theme-d2'
: 'w3-theme-d4';
}
get_content() {
let content = this.message?.content;
if (this.message?.decrypted?.type == 'post') {
content = this.message.decrypted;
}
return content;
}
render_raw_button() {
let content = this.get_content();
let raw_button;
switch (this.format) {
case 'raw':
if (content?.type == 'post' || content?.type == 'blog') {
raw_button = html`<button
class="w3-button w3-theme-d1"
@click=${() => (this.format = 'md')}
>
Markdown
</button>`;
} else {
raw_button = html`<button
class="w3-button w3-theme-d1"
@click=${() => (this.format = 'message')}
>
Message
</button>`;
}
break;
case 'md':
raw_button = html`<button
class="w3-button w3-theme-d1"
@click=${() => (this.format = 'message')}
>
Message
</button>`;
break;
case 'decrypted':
raw_button = html`<button
class="w3-button w3-theme-d1"
@click=${() => (this.format = 'raw')}
>
Raw
</button>`;
break;
default:
if (this.message.decrypted) {
raw_button = html`<button
class="w3-button w3-theme-d1"
@click=${() => (this.format = 'decrypted')}
>
Decrypted
</button>`;
} else {
raw_button = html`<button
class="w3-button w3-theme-d1"
@click=${() => (this.format = 'raw')}
>
Raw
</button>`;
}
break;
}
return raw_button;
}
render_header() {
let is_encrypted = this.message?.decrypted
? html`<span class="w3-bar-item">🔓</span>`
: typeof this.message?.content == 'string'
? html`<span class="w3-bar-item">🔒</span>`
: undefined;
return html`
<header class="w3-bar">
<span class="w3-bar-item">
<tf-user id=${this.message.author} .users=${this.users}></tf-user>
</span>
${is_encrypted}
<span class="w3-bar-item w3-right">${this.render_raw_button()}</span>
<span class="w3-bar-item w3-right" style="text-wrap: nowrap"
><a target="_top" href=${'#' + encodeURIComponent(this.message.id)}
>%</a
>
${new Date(this.message.timestamp).toLocaleString()}</span
>
</header>
`;
}
render_frame(inner) {
return html`
<style>
code {
white-space: pre-wrap;
overflow-wrap: break-word;
}
div {
overflow-wrap: anywhere;
}
img {
max-width: 100%;
height: auto;
display: block;
}
</style>
<div
class="w3-card-4 ${this.class_background()} w3-border-theme w3-margin-top"
style="overflow: auto; overflow-wrap: anywhere; display: block; max-width: 100%"
>
${inner}
</div>
`;
}
render_small_frame(inner) {
let self = this;
return this.render_frame(html`
${self.render_header()}
${self.format == 'raw'
? html`<div class="w3-container">${self.render_raw()}</div>`
: inner}
${self.render_votes()}
${(self.message.child_messages || []).map(
(x) => html`
<tf-message
.message=${x}
whoami=${self.whoami}
.users=${self.users}
.drafts=${self.drafts}
.expanded=${self.expanded}
channel=${self.channel}
channel_unread=${self.channel_unread}
></tf-message>
`
)}
`);
}
render_actions() {
let content = this.get_content();
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}
author=${this.message.author}
></tf-compose>
`
: html`
<button class="w3-button w3-theme-d1" @click=${this.show_reply}>
Reply
</button>
`;
return html`
<div class="w3-section w3-container">
${reply}
<button class="w3-button w3-theme-d1" @click=${this.react}>
React
</button>
${this.render_children()}
</div>
`;
} }
render() { render() {
@ -537,49 +245,59 @@ class TfMessageElement extends LitElement {
if (this.message?.decrypted?.type == 'post') { if (this.message?.decrypted?.type == 'post') {
content = this.message.decrypted; content = this.message.decrypted;
} }
let class_background = this.class_background();
let self = this; let self = this;
let raw_button;
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>`;
} else {
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>`;
break;
case 'decrypted':
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>`;
} else {
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">
<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}
${self.render_votes()}
</div>
`;
}
if (this.message?.type === 'contact_group') { if (this.message?.type === 'contact_group') {
return this.render_frame( return html`
html` ${this.message.messages.map( <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">
(x) => ${this.message.messages.map(x =>
html`<tf-message html`<tf-message .message=${x} whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} .expanded=${this.expanded}></tf-message>`
.message=${x}
whoami=${this.whoami}
.users=${this.users}
.drafts=${this.drafts}
.expanded=${this.expanded}
channel=${this.channel}
channel_unread=${this.channel_unread}
></tf-message>`
)}`
);
} else if (this.message.placeholder) {
return this.render_frame(
html`<div class="w3-padding">
<p>
<a target="_top" href=${'#' + encodeURIComponent(this.message.id)}
>${this.message.id}</a
>
(placeholder)
</p>
<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}
channel=${this.channel}
channel_unread=${this.channel_unread}
></tf-message>
`
)} )}
</div>` </div>`;
); } else if (this.message.placeholder) {
} 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') { if (content.type == 'about') {
let name; let name;
let image; let image;
@ -589,7 +307,7 @@ class TfMessageElement extends LitElement {
} }
if (content.image !== undefined) { if (content.image !== undefined) {
image = html` image = html`
<div><img src=${'/' + (typeof content.image?.link == 'string' ? content.image.link : content.image) + '/view'} style="width: 256px; height: auto"></img></div> <div><img src=${'/' + (typeof(content.image?.link) == 'string' ? content.image.link : content.image) + '/view'} style="width: 256px; height: auto"></img></div>
`; `;
} }
if (content.description !== undefined) { if (content.description !== undefined) {
@ -599,39 +317,42 @@ class TfMessageElement extends LitElement {
</div> </div>
`; `;
} }
let update = let update = content.about == this.message.author ?
content.about == this.message.author html`<div style="font-weight: bold">Updated profile.</div>` :
? 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>`;
: html`<div style="font-weight: bold"> return small_frame(html`
Updated profile for ${update}
<tf-user id=${content.about} .users=${this.users}></tf-user>. ${name}
</div>`; ${image}
return this.render_small_frame(html` ${description}
<div class="w3-container">
<p>${update} ${name} ${image} ${description}</p>
</div>
`); `);
} else if (content.type == 'contact') { } else if (content.type == 'contact') {
return html` return html`
<div class="w3-padding"> <div>
<tf-user id=${this.message.author} .users=${this.users}></tf-user> <tf-user id=${this.message.author} .users=${this.users}></tf-user>
is is
${content.blocking === true ${
? 'blocking' content.blocking === true ? 'blocking' :
: content.blocking === false content.blocking === false ? 'no longer blocking' :
? 'no longer blocking' content.following === true ? 'following' :
: content.following === true content.following === false ? 'no longer following' :
? 'following' '?'
: content.following === false }
? 'no longer following' <tf-user id=${this.message.content.contact} .users=${this.users}></tf-user>
: '?'}
<tf-user
id=${this.message.content.contact}
.users=${this.users}
></tf-user>
</div> </div>
`; `;
} else if (content.type == 'post') { } else if (content.type == 'post') {
let reply = (this.drafts[this.message?.id] !== undefined) ? html`
<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 self = this; let self = this;
let body; let body;
switch (this.format) { switch (this.format) {
@ -639,65 +360,110 @@ class TfMessageElement extends LitElement {
body = this.render_raw(); body = this.render_raw();
break; break;
case 'md': case 'md':
body = html`<code body = html`<code style="white-space: pre-wrap; overflow-wrap: anywhere">${content.text}</code>`;
style="white-space: pre-wrap; overflow-wrap: anywhere"
>${content.text}</code
>`;
break; break;
case 'message': case 'message':
body = unsafeHTML(tfutils.markdown(content.text)); body = unsafeHTML(tfutils.markdown(content.text));
break; break;
case 'decrypted': case 'decrypted':
body = this.render_json(content); body = html`<pre style="white-space: pre-wrap; overflow-wrap: anywhere">${JSON.stringify(content, null, 2)}</pre>`;
break; break;
} }
let content_warning = html` let content_warning = html`
<div <div class="w3-panel w3-round-xlarge w3-blue" style="cursor: pointer" @click=${x => this.toggle_expanded(':cw')}><p>${content.contentWarning}</p></div>
class="w3-panel w3-round-xlarge w3-theme-l4" `;
style="cursor: pointer" let content_html =
@click=${(x) => this.toggle_expanded(':cw')} html`
> ${this.render_channels()}
<p>${content.contentWarning}</p> <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 {
white-space: pre-wrap;
overflow-wrap: break-word;
}
div {
overflow-wrap: anywhere;
}
img {
max-width: 100%;
height: auto;
display: block;
}
</style>
<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>${raw_button}</span>
</div>
${payload}
${this.render_votes()}
<p>
${reply}
<button class="w3-button w3-dark-grey" @click=${this.react}>React</button>
</p>
${this.render_children()}
</div> </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;
return this.render_frame(html`
${this.render_header()}
<div class="w3-container">${payload}</div>
${this.render_votes()} ${this.render_actions()}
</div>
`);
} else if (content.type === 'issue') { } else if (content.type === 'issue') {
return this.render_frame(html` let is_encrypted = this.message?.decrypted ? html`<span style="align-self: center">🔓</span>` : undefined;
${this.render_header()} ${content.text} ${this.render_votes()} let style_background = this.message?.decrypted ? 'rgba(255, 0, 0, 0.2)' : 'rgba(255, 255, 255, 0.1)';
<footer class="w3-container"> return html`
<button class="w3-button w3-theme-d1" @click=${this.react}> <style>
React code {
</button> white-space: pre-wrap;
overflow-wrap: break-word;
}
div {
overflow-wrap: anywhere;
}
img {
max-width: 100%;
height: auto;
display: block;
}
</style>
<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>${raw_button}</span>
</div>
${content.text}
${this.render_votes()}
<p>
<button class="w3-button w3-dark-grey" @click=${this.react}>React</button>
</p>
${this.render_children()} ${this.render_children()}
</footer> </div>
`); `;
} else if (content.type === 'blog') { } else if (content.type === 'blog') {
let self = this; 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; self.blog_data = data;
}); });
let payload = this.expanded[(this.message.id || '') + ':blog'] let payload =
? html`<div> this.expanded[(this.message.id || '') + ':blog'] ?
${this.blog_data html`<div>${this.blog_data ? unsafeHTML(tfutils.markdown(this.blog_data)) : 'Loading...'}</div>` :
? unsafeHTML(tfutils.markdown(this.blog_data)) undefined;
: 'Loading...'}
</div>`
: undefined;
let body; let body;
switch (this.format) { switch (this.format) {
case 'raw': case 'raw':
@ -710,7 +476,7 @@ class TfMessageElement extends LitElement {
body = html` body = html`
<div <div
style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer" style="border: 1px solid #fff; border-radius: 1em; padding: 8px; margin: 4px; cursor: pointer"
@click=${(x) => self.toggle_expanded(':blog')}> @click=${x => self.toggle_expanded(':blog')}>
<h2>${content.title}</h2> <h2>${content.title}</h2>
<div style="display: flex; flex-direction: row"> <div style="display: flex; flex-direction: row">
<img src=/${content.thumbnail}/view></img> <img src=/${content.thumbnail}/view></img>
@ -721,67 +487,84 @@ class TfMessageElement extends LitElement {
`; `;
break; break;
} }
return this.render_frame(html` let reply = (this.drafts[this.message?.id] !== undefined) ? html`
${this.render_header()} <tf-compose
<div>${body}</div> whoami=${this.whoami}
${this.render_mentions()} ${this.render_votes()} .users=${this.users}
${this.render_actions()} 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>
`;
return html`
<style>
code {
white-space: pre-wrap;
overflow-wrap: break-word;
}
div {
overflow-wrap: anywhere;
}
img {
max-width: 100%;
height: auto;
display: block;
}
</style>
<div 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>${raw_button}</span>
</div>
<div>${body}</div>
${this.render_mentions()}
<div>
${reply}
<button class="w3-button w3-dark-grey" @click=${this.react}>React</button>
</div>
${this.render_votes()}
${this.render_children()}
</div>
`;
} else if (content.type === 'pub') { } else if (content.type === 'pub') {
return this.render_small_frame( return small_frame(html`
html` <style> <style>
span { span {
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
</style> </style>
<div class="w3-padding"> <span>
<div> <div>
🍻 🍻 <tf-user .users=${this.users} id=${content.address.key}></tf-user>
<tf-user </div>
.users=${this.users} <pre>${content.address.host}:${content.address.port}</pre>
id=${content.address.key} </span>`);
></tf-user>
</div>
<pre>${content.address.host}:${content.address.port}</pre>
</div>`
);
} else if (content.type === 'channel') { } else if (content.type === 'channel') {
return this.render_small_frame(html` return small_frame(html`
<div class="w3-container"> <div>
<p> ${content.subscribed ? 'subscribed to' : 'unsubscribed from'} <a href=${'#q=' + encodeURIComponent('#' + content.channel)}>#${content.channel}</a>
${content.subscribed ? 'subscribed to' : 'unsubscribed from'}
<a href=${'#' + encodeURIComponent('#' + content.channel)}
>#${content.channel}</a
>
</p>
</div> </div>
`); `);
} else if (typeof this.message.content == 'string') { } else if (typeof(this.message.content) == 'string') {
if (this.message?.decrypted) { if (this.message?.decrypted) {
if (this.format == 'decrypted') { if (this.format == 'decrypted') {
return this.render_small_frame( return small_frame(html`<span>🔓</span><pre>${JSON.stringify(this.message.decrypted, null, 2)}</pre>`);
html`<span class="w3-container">🔓</span> ${this.render_json(
this.message.decrypted
)}`
);
} else { } else {
return this.render_small_frame( return small_frame(html`<span>🔓</span><div>${this.message.decrypted.type}</div>`);
html`<span class="w3-container">🔓</span>
<div class="w3-container">${this.message.decrypted.type}</div>`
);
} }
} else { } else {
return this.render_small_frame(); return small_frame(html`<span>🔒</span>`);
} }
} else { } else {
return this.render_small_frame( return small_frame(html`<div><b>type</b>: ${content.type}</div>`);
html`<div class="w3-container"><b>type</b>: ${content.type}</div>`
);
} }
} else if (typeof this.message.content == 'string') {
return this.render_small_frame();
} else { } else {
return this.render_small_frame(this.render_raw()); return small_frame(this.render_raw());
} }
} }
} }

View File

@ -1,4 +1,4 @@
import {LitElement, html, unsafeHTML, repeat, until} from './lit-all.min.js'; import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js'; import * as tfrpc from '/static/tfrpc.js';
import {styles} from './tf-styles.js'; import {styles} from './tf-styles.js';
@ -11,8 +11,6 @@ class TfNewsElement extends LitElement {
following: {type: Array}, following: {type: Array},
drafts: {type: Object}, drafts: {type: Object},
expanded: {type: Object}, expanded: {type: Object},
channel: {type: String},
channel_unread: {type: Number},
}; };
} }
@ -27,7 +25,6 @@ class TfNewsElement extends LitElement {
this.following = []; this.following = [];
this.drafts = {}; this.drafts = {};
this.expanded = {}; this.expanded = {};
this.channel_unread = -1;
} }
process_messages(messages) { process_messages(messages) {
@ -36,13 +33,12 @@ class TfNewsElement extends LitElement {
console.log('processing', messages.length, 'messages'); console.log('processing', messages.length, 'messages');
function ensure_message(id, rowid) { function ensure_message(id) {
let found = messages_by_id[id]; let found = messages_by_id[id];
if (found) { if (found) {
return found; return found;
} else { } else {
let added = { let added = {
rowid: rowid,
id: id, id: id,
placeholder: true, placeholder: true,
content: '"placeholder"', content: '"placeholder"',
@ -57,7 +53,7 @@ class TfNewsElement extends LitElement {
function link_message(message) { function link_message(message) {
if (message.content.type === 'vote') { if (message.content.type === 'vote') {
let parent = ensure_message(message.content.vote.link, message.rowid); let parent = ensure_message(message.content.vote.link);
if (!parent.votes) { if (!parent.votes) {
parent.votes = []; parent.votes = [];
} }
@ -65,15 +61,15 @@ class TfNewsElement extends LitElement {
message.parent_message = message.content.vote.link; message.parent_message = message.content.vote.link;
} else if (message.content.type == 'post') { } else if (message.content.type == 'post') {
if (message.content.root) { if (message.content.root) {
if (typeof message.content.root === 'string') { if (typeof(message.content.root) === 'string') {
let m = ensure_message(message.content.root, message.rowid); let m = ensure_message(message.content.root);
if (!m.child_messages) { if (!m.child_messages) {
m.child_messages = []; m.child_messages = [];
} }
m.child_messages.push(message); m.child_messages.push(message);
message.parent_message = message.content.root; message.parent_message = message.content.root;
} else { } else {
let m = ensure_message(message.content.root[0], message.rowid); let m = ensure_message(message.content.root[0]);
if (!m.child_messages) { if (!m.child_messages) {
m.child_messages = []; m.child_messages = [];
} }
@ -93,7 +89,8 @@ class TfNewsElement extends LitElement {
for (let message of messages) { for (let message of messages) {
try { try {
message.content = JSON.parse(message.content); message.content = JSON.parse(message.content);
} catch {} } catch {
}
if (!messages_by_id[message.id]) { if (!messages_by_id[message.id]) {
messages_by_id[message.id] = message; messages_by_id[message.id] = message;
link_message(message); link_message(message);
@ -103,12 +100,8 @@ class TfNewsElement extends LitElement {
message.parent_message = placeholder.parent_message; message.parent_message = placeholder.parent_message;
message.child_messages = placeholder.child_messages; message.child_messages = placeholder.child_messages;
message.votes = placeholder.votes; message.votes = placeholder.votes;
if ( if (placeholder.parent_message && messages_by_id[placeholder.parent_message]) {
placeholder.parent_message && let children = messages_by_id[placeholder.parent_message].child_messages;
messages_by_id[placeholder.parent_message]
) {
let children =
messages_by_id[placeholder.parent_message].child_messages;
children.splice(children.indexOf(placeholder), 1); children.splice(children.indexOf(placeholder), 1);
children.push(message); children.push(message);
} }
@ -123,10 +116,7 @@ class TfNewsElement extends LitElement {
let latest = 0; let latest = 0;
for (let message of messages || []) { for (let message of messages || []) {
if (message.latest_subtree_timestamp === undefined) { if (message.latest_subtree_timestamp === undefined) {
message.latest_subtree_timestamp = Math.max( message.latest_subtree_timestamp = Math.max(message.timestamp ?? 0, this.update_latest_subtree_timestamp(message.child_messages));
message.timestamp ?? 0,
this.update_latest_subtree_timestamp(message.child_messages)
);
} }
latest = Math.max(latest, message.latest_subtree_timestamp); latest = Math.max(latest, message.latest_subtree_timestamp);
} }
@ -137,22 +127,20 @@ class TfNewsElement extends LitElement {
function recursive_sort(messages, top) { function recursive_sort(messages, top) {
if (messages) { if (messages) {
if (top) { if (top) {
messages.sort( messages.sort((a, b) => b.latest_subtree_timestamp - a.latest_subtree_timestamp);
(a, b) => b.latest_subtree_timestamp - a.latest_subtree_timestamp
);
} else { } else {
messages.sort((a, b) => a.timestamp - b.timestamp); messages.sort((a, b) => a.timestamp - b.timestamp);
} }
for (let message of messages) { for (let message of messages) {
recursive_sort(message.child_messages, false); recursive_sort(message.child_messages, false);
} }
return messages.map((x) => Object.assign({}, x)); return messages.map(x => Object.assign({}, x));
} else { } else {
return {}; return {};
} }
} }
let roots = Object.values(messages_by_id).filter((x) => !x.parent_message); let roots = Object.values(messages_by_id).filter(x => !x.parent_message);
this.update_latest_subtree_timestamp(roots); this.update_latest_subtree_timestamp(roots);
return recursive_sort(roots, true); return recursive_sort(roots, true);
} }
@ -166,7 +154,6 @@ class TfNewsElement extends LitElement {
} else { } else {
if (group.length > 0) { if (group.length > 0) {
result.push({ result.push({
rowid: Math.max(...group.map((x) => x.rowid)),
type: 'contact_group', type: 'contact_group',
messages: group, messages: group,
}); });
@ -175,56 +162,15 @@ class TfNewsElement extends LitElement {
result.push(message); result.push(message);
} }
} }
if (group.length > 0) {
result.push({
rowid: Math.max(...group.map((x) => x.rowid)),
type: 'contact_group',
messages: group,
});
}
return result; return result;
} }
load_and_render(messages) { load_and_render(messages) {
let messages_by_id = this.process_messages(messages); let messages_by_id = this.process_messages(messages);
let final_messages = this.group_following( let final_messages = this.group_following(this.finalize_messages(messages_by_id));
this.finalize_messages(messages_by_id)
);
let unread_rowid = -1;
for (let message of final_messages) {
if (message.rowid >= this.channel_unread) {
unread_rowid = message.rowid;
}
}
return html` return html`
<div> <div style="display: flex; flex-direction: column">
${repeat( ${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,
(x) => x.id,
(x) => html`
<tf-message
.message=${x}
whoami=${this.whoami}
.users=${this.users}
.drafts=${this.drafts}
.expanded=${this.expanded}
collapsed="true"
channel=${this.channel}
channel_unread=${this.channel_unread}
></tf-message>
${x.rowid == unread_rowid
? html`<div style="display: flex; flex-direction: row">
<div
style="border-bottom: 1px solid #f00; flex: 1; align-self: center; height: 1px"
></div>
<div style="color: #f00; padding: 8px">unread</div>
<div
style="border-bottom: 1px solid #f00; flex: 1; align-self: center; height: 1px"
></div>
</div>`
: undefined}
`
)}
</div> </div>
`; `;
} }

View File

@ -11,6 +11,7 @@ class TfProfileElement extends LitElement {
id: {type: String}, id: {type: String},
users: {type: Object}, users: {type: Object},
size: {type: Number}, size: {type: Number},
server_follows_me: {type: Boolean},
following: {type: Boolean}, following: {type: Boolean},
blocking: {type: Boolean}, blocking: {type: Boolean},
}; };
@ -26,6 +27,7 @@ class TfProfileElement extends LitElement {
this.id = null; this.id = null;
this.users = {}; this.users = {};
this.size = 0; this.size = 0;
this.server_follows_me = undefined;
} }
async load() { async load() {
@ -34,46 +36,50 @@ class TfProfileElement extends LitElement {
this.following = undefined; this.following = undefined;
this.blocking = undefined; this.blocking = undefined;
let result = await tfrpc.rpc.query( let result = await tfrpc.rpc.query(`
`
SELECT json_extract(content, '$.following') AS following SELECT json_extract(content, '$.following') AS following
FROM messages WHERE author = ? AND FROM messages WHERE author = ? AND
json_extract(content, '$.type') = 'contact' AND json_extract(content, '$.type') = 'contact' AND
json_extract(content, '$.contact') = ? AND json_extract(content, '$.contact') = ? AND
following IS NOT NULL following IS NOT NULL
ORDER BY sequence DESC LIMIT 1 ORDER BY sequence DESC LIMIT 1
`, `, [this.whoami, this.id]);
[this.whoami, this.id]
);
this.following = result?.[0]?.following ?? false; this.following = result?.[0]?.following ?? false;
result = await tfrpc.rpc.query( result = await tfrpc.rpc.query(`
`
SELECT json_extract(content, '$.blocking') AS blocking SELECT json_extract(content, '$.blocking') AS blocking
FROM messages WHERE author = ? AND FROM messages WHERE author = ? AND
json_extract(content, '$.type') = 'contact' AND json_extract(content, '$.type') = 'contact' AND
json_extract(content, '$.contact') = ? AND json_extract(content, '$.contact') = ? AND
blocking IS NOT NULL blocking IS NOT NULL
ORDER BY sequence DESC LIMIT 1 ORDER BY sequence DESC LIMIT 1
`, `, [this.whoami, this.id]);
[this.whoami, this.id]
);
this.blocking = result?.[0]?.blocking ?? false; this.blocking = result?.[0]?.blocking ?? false;
} }
} }
async initial_load() {
this.server_follows_me = undefined;
let server_id = await tfrpc.rpc.getServerIdentity();
let followed = await tfrpc.rpc.query(`
SELECT json_extract(content, '$.following') AS following
FROM messages
WHERE author = ? AND
json_extract(content, '$.type') = 'contact' AND
json_extract(content, '$.contact') = ? ORDER BY sequence DESC LIMIT 1
`, [server_id, this.whoami]);
let is_followed = false;
for (let row of followed) {
is_followed = row.following != 0;
}
this.server_follows_me = is_followed;
}
modify(change) { modify(change) {
tfrpc.rpc tfrpc.rpc.appendMessage(this.whoami,
.appendMessage( Object.assign({
this.whoami, type: 'contact',
Object.assign( contact: this.id,
{ }, change)).catch(function(error) {
type: 'contact',
contact: this.id,
},
change
)
)
.catch(function (error) {
alert(error?.message); alert(error?.message);
}); });
} }
@ -116,14 +122,11 @@ class TfProfileElement extends LitElement {
message[key] = this.editing[key]; message[key] = this.editing[key];
} }
} }
tfrpc.rpc tfrpc.rpc.appendMessage(this.whoami, message).then(function() {
.appendMessage(this.whoami, message) self.editing = null;
.then(function () { }).catch(function(error) {
self.editing = null; alert(error?.message);
}) });
.catch(function (error) {
alert(error?.message);
});
} }
discard_edits() { discard_edits() {
@ -134,39 +137,44 @@ class TfProfileElement extends LitElement {
let self = this; let self = this;
let input = document.createElement('input'); let input = document.createElement('input');
input.type = 'file'; input.type = 'file';
input.onchange = function (event) { input.onchange = function(event) {
let file = event.target.files[0]; let file = event.target.files[0];
file file.arrayBuffer().then(function(buffer) {
.arrayBuffer() let bin = Array.from(new Uint8Array(buffer));
.then(function (buffer) { return tfrpc.rpc.store_blob(bin);
let bin = Array.from(new Uint8Array(buffer)); }).then(function(id) {
return tfrpc.rpc.store_blob(bin); self.editing = Object.assign({}, self.editing, {image: id});
}) console.log(self.editing);
.then(function (id) { }).catch(function(e) {
self.editing = Object.assign({}, self.editing, {image: id}); alert(e.message);
console.log(self.editing); });
})
.catch(function (e) {
alert(e.message);
});
}; };
input.click(); input.click();
} }
copy_id() { async server_follow_me(follow) {
navigator.clipboard.writeText(this.id); try {
await tfrpc.rpc.setServerFollowingMe(this.whoami, follow);
} catch (e) {
console.log(e);
}
try {
await this.initial_load();
} catch (e) {
console.log(e);
}
} }
render() { render() {
if (this.id == this.whoami && this.editing && this.server_follows_me === undefined) {
this.initial_load();
}
this.load(); this.load();
let self = this; let self = this;
let profile = this.users[this.id] || {}; let profile = this.users[this.id] || {};
tfrpc.rpc tfrpc.rpc.query(
.query( `SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`,
`SELECT SUM(LENGTH(content)) AS size FROM messages WHERE author = ?`, [this.id]).then(function(result) {
[this.id]
)
.then(function (result) {
self.size = result[0].size; self.size = result[0].size;
}); });
let edit; let edit;
@ -174,105 +182,76 @@ class TfProfileElement extends LitElement {
let block; let block;
if (this.id === this.whoami) { if (this.id === this.whoami) {
if (this.editing) { 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>`;
} 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>`;
}
edit = html` edit = html`
<button <button class="w3-button w3-dark-grey" @click=${this.save_edits}>Save Profile</button>
id="save_profile" <button class="w3-button w3-dark-grey" @click=${this.discard_edits}>Discard</button>
class="w3-button w3-theme-d1" ${server_follow}
@click=${this.save_edits}
>
Save Profile
</button>
<button class="w3-button w3-theme-d1" @click=${this.discard_edits}>
Discard
</button>
`; `;
} else { } else {
edit = html`<button edit = html`<button class="w3-button w3-dark-grey" @click=${this.edit}>Edit Profile</button>`;
id="edit_profile"
class="w3-button w3-theme-d1"
@click=${this.edit}
>
Edit Profile
</button>`;
} }
} }
if (this.id !== this.whoami && this.following !== undefined) { if (this.id !== this.whoami &&
follow = this.following this.following !== undefined) {
? html`<button class="w3-button w3-theme-d1" @click=${this.unfollow}> follow =
Unfollow this.following ?
</button>` html`<button class="w3-button w3-dark-grey" @click=${this.unfollow}>Unfollow</button>` :
: html`<button class="w3-button w3-theme-d1" @click=${this.follow}> html`<button class="w3-button w3-dark-grey" @click=${this.follow}>Follow</button>`;
Follow
</button>`;
} }
if (this.id !== this.whoami && this.blocking !== undefined) { if (this.id !== this.whoami &&
block = this.blocking this.blocking !== undefined) {
? html`<button class="w3-button w3-theme-d1" @click=${this.unblock}> block =
Unblock this.blocking ?
</button>` html`<button class="w3-button w3-dark-grey" @click=${this.unblock}>Unblock</button>` :
: html`<button class="w3-button w3-theme-d1" @click=${this.block}> html`<button class="w3-button w3-dark-grey" @click=${this.block}>Block</button>`;
Block
</button>`;
} }
let edit_profile = this.editing let edit_profile = this.editing ? html`
? html`
<div style="flex: 1 0 50%; display: flex; flex-direction: column; gap: 8px"> <div style="flex: 1 0 50%; display: flex; flex-direction: column; gap: 8px">
<div> <div class="w3-container">
<label for="name">Name:</label> <div>
<input class="w3-input w3-theme-d1" type="text" id="name" value=${this.editing.name} @input=${(event) => (this.editing = Object.assign({}, this.editing, {name: event.srcElement.value}))} placeholder="Choose a name"></input> <label for="name">Name:</label>
</div> <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><label for="description">Description:</label></div> </div>
<textarea class="w3-input w3-theme-d1" style="resize: vertical" rows="8" id="description" @input=${(event) => (this.editing = Object.assign({}, this.editing, {description: event.srcElement.value}))} placeholder="Tell people a little bit about yourself here, if you like.">${this.editing.description}</textarea> <div><label for="description">Description:</label></div>
<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>
<label for="public_web_hosting">Public Web Hosting:</label> <div>
<input class="w3-check w3-theme-d1" type="checkbox" id="public_web_hosting" ?checked=${this.editing.publicWebHosting} @input=${(event) => (self.editing = Object.assign({}, self.editing, {publicWebHosting: event.srcElement.checked}))}></input> <label for="public_web_hosting">Public Web Hosting:</label>
</div> <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-theme-d1" @click=${this.attach_image}>Attach Image</button> <div>
</div> <button class="w3-button w3-dark-grey" @click=${this.attach_image}>Attach Image</button>
</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 class="w3-card-4 w3-container w3-theme-d3" style="box-sizing: border-box">
<header class="w3-container">
<p><tf-user id=${this.id} .users=${this.users}></tf-user> (${tfutils.human_readable_size(this.size)})</p>
</header>
<div class="w3-container">
<div class="w3-margin-bottom" style="display: flex; flex-direction: row">
<input type="text" class="w3-input w3-border w3-theme-d1" style="display: flex 1 1" readonly value=${this.id}></input>
<button class="w3-button w3-theme-d1 w3-ripple" style="flex: 0 0 auto" @click=${this.copy_id}>Copy</button>
</div>
<div style="display: flex; flex-direction: row; gap: 1em">
${edit_profile}
<div style="flex: 1 0 50%">
${
image
? html`<div><img src=${'/' + image + '/view'} style="width: 256px; height: auto"></img></div>`
: html`<div>
<div class="w3-jumbo">😎</div>
<div><i>Profile image not set.</i></div>
</div>`
}
<div>${unsafeHTML(tfutils.markdown(description))}</div>
</div> </div>
</div> </div>
<div> </div>` : null;
Following ${profile.following} identities. let image = typeof(profile.image) == 'string' ? profile.image : profile.image?.link;
Followed by ${profile.followed} identities. image = this.editing?.image ?? image;
Blocking ${profile.blocking} identities. let description = this.editing?.description ?? profile.description;
Blocked by ${profile.blocked} identities. return html`<div style="border: 2px solid black; background-color: rgba(255, 255, 255, 0.2); padding: 16px">
<tf-user id=${this.id} .users=${this.users}></tf-user> (${tfutils.human_readable_size(this.size)})
<div style="display: flex; flex-direction: row; gap: 1em">
${edit_profile}
<div style="flex: 1 0 50%">
<div><img src=${'/' + image + '/view'} style="width: 256px; height: auto"></img></div>
<div>${unsafeHTML(tfutils.markdown(description))}</div>
</div> </div>
</div> </div>
<footer class="w3-container"> <div>
<p> Following ${profile.following} identities.
${edit} Followed by ${profile.followed} identities.
${follow} Blocking ${profile.blocking} identities.
${block} Blocked by ${profile.blocked} identities.
</p> </div>
</footer> <div>
${edit}
${follow}
${block}
</div>
</div>`; </div>`;
} }
} }

View File

@ -1,72 +0,0 @@
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
import {styles} from './tf-styles.js';
class TfReactionsModalElement extends LitElement {
static get properties() {
return {
users: {type: Object},
votes: {type: Array},
};
}
static styles = styles;
constructor() {
super();
this.votes = [];
this.users = {};
}
clear() {
this.votes = [];
}
render() {
let self = this;
return this.votes?.length
? html` <div
class="w3-modal w3-animate-opacity"
style="display: block; box-sizing: border-box; z-index: 10"
@click=${this.clear}
>
<div
class="w3-modal-content w3-card-4 w3-theme-d1"
onclick="event.stopPropagation()"
>
<div class="w3-container w3-padding">
<header class="w3-container">
<h2>Reactions</h2>
<span class="w3-button w3-display-topright" @click=${this.clear}
>&times;</span
>
</header>
<ul class="w3-theme-dark w3-container w3-ul">
${this.votes.map(
(x) => html`
<li class="w3-bar">
<span class="w3-bar-item"
>${x?.content?.vote?.expression}</span
>
<tf-user
class="w3-bar-item"
id=${x.author}
.users=${this.users}
></tf-user>
<span class="w3-bar-item w3-right"
>${new Date(x?.timestamp).toLocaleString()}</span
>
</li>
`
)}
</ul>
<footer class="w3-container w3-padding">
<button class="w3-button" @click=${this.clear}>Close</button>
</footer>
</div>
</div>
</div>`
: undefined;
}
}
customElements.define('tf-reactions-modal', TfReactionsModalElement);

View File

@ -1,52 +1,67 @@
import {css, unsafeCSS} from './lit-all.min.js'; import {css} from './lit-all.min.js';
const tf = css` const tf = css`
img { a:link {
max-width: min(640px, 100%); color: #bbf;
max-height: min(480px, auto); }
}
.tab { a:visited {
border: 0; color: #ddd;
padding: 8px; }
margin: 0px;
cursor: pointer;
}
.tab:disabled { a:hover {
color: #088; color: #ddf;
background-color: #fff; }
}
.content_warning { img {
border: 1px solid #fff; max-width: min(640px, 100%);
border-radius: 1em; max-height: min(480px, auto);
padding: 8px; }
margin: 4px;
}
div.img_caption { .tab {
color: #888; border: 0;
cursor: pointer; padding: 8px;
} margin: 0px;
cursor: pointer;
}
div.img_caption::after { .tab:disabled {
content: ' ±'; color: #088;
} background-color: #fff;
}
pre code { .content_warning {
display: block; border: 1px solid #fff;
padding: 8px; border-radius: 1em;
} padding: 8px;
margin: 4px;
}
blockquote { div.img_caption {
border-left: 4px solid #fff; color: #888;
padding: 8px; cursor: pointer;
padding-left: 12px; }
}
div.img_caption::after {
content: ' ±';
}
code {
background-color: #444;
padding-left: 3px;
padding-right: 3px;
border: 1px dotted #fff;
border-radius: 4px;
}
blockquote {
background-color: #607d8b;
border-left: 4px solid #fff;
padding: 8px;
padding-left: 12px;
}
`; `;
// prettier-ignore
const w3 = css` const w3 = css`
/* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */ /* W3.CSS 4.15 December 2020 by Jan Egil and Borge Refsnes */
html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit} html{box-sizing:border-box}*,*:before,*:after{box-sizing:inherit}
@ -285,165 +300,4 @@ hr{border:0;border-top:1px solid #eee;margin:20px 0}
.w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important} .w3-border-pale-yellow,.w3-hover-border-pale-yellow:hover{border-color:#ffffcc!important}.w3-border-pale-blue,.w3-hover-border-pale-blue:hover{border-color:#e7ffff!important}
`; `;
function rgb_to_hsl(r, g, b) { export let styles = [tf, w3];
let min,
max,
i,
l,
s,
maxcolor,
h,
rgb = [];
rgb[0] = r / 255;
rgb[1] = g / 255;
rgb[2] = b / 255;
min = rgb[0];
max = rgb[0];
maxcolor = 0;
for (i = 0; i < rgb.length - 1; i++) {
if (rgb[i + 1] <= min) {
min = rgb[i + 1];
}
if (rgb[i + 1] >= max) {
max = rgb[i + 1];
maxcolor = i + 1;
}
}
if (maxcolor == 0) {
h = (rgb[1] - rgb[2]) / (max - min);
}
if (maxcolor == 1) {
h = 2 + (rgb[2] - rgb[0]) / (max - min);
}
if (maxcolor == 2) {
h = 4 + (rgb[0] - rgb[1]) / (max - min);
}
if (isNaN(h)) {
h = 0;
}
h = h * 60;
if (h < 0) {
h = h + 360;
}
l = (min + max) / 2;
if (min == max) {
s = 0;
} else {
if (l < 0.5) {
s = (max - min) / (max + min);
} else {
s = (max - min) / (2 - max - min);
}
}
s = s;
return [h, s, l];
}
function hex_to_rgb(hex) {
if (hex.charAt(0) == '#') {
hex = hex.substring(1);
}
return [
parseInt(hex.substring(0, 2), 16),
parseInt(hex.substring(2, 4), 16),
parseInt(hex.substring(4, 6), 16),
];
}
function hsl_to_rgb(hue, sat, light) {
let t2;
hue /= 60;
if (light <= 0.5) {
t2 = light * (sat + 1);
} else {
t2 = light + sat - light * sat;
}
let t1 = light * 2 - t2;
return [
hue_to_rgb(t1, t2, hue + 2) * 255,
hue_to_rgb(t1, t2, hue) * 255,
hue_to_rgb(t1, t2, hue - 2) * 255,
];
}
function hue_to_rgb(t1, t2, hue) {
if (hue < 0) {
hue += 6;
}
if (hue >= 6) {
hue -= 6;
}
if (hue < 1) {
return (t2 - t1) * hue + t1;
} else if (hue < 3) {
return t2;
} else if (hue < 4) {
return (t2 - t1) * (4 - hue) + t1;
} else {
return t1;
}
}
function rgb_to_hex(rgb) {
const hex_pair = (x) => Math.floor(x).toString(16).padStart(2, '0');
return `#${hex_pair(rgb[0])}${hex_pair(rgb[1])}${hex_pair(rgb[2])}`;
}
function is_dark(hex, value) {
let [r, g, b] = hex_to_rgb(hex);
return (r * 299 + g * 587 + b * 114) / 1000 < value;
}
function generated() {
let now = new Date();
let k_color = rgb_to_hex([
(now.getDay() * 128) / 6,
(now.getHours() * 128) / 23,
(now.getSeconds() * 128) / 59,
]);
//let k_color = '#034f84';
//let k_color = rgb_to_hex([Math.random() * 256, Math.random() * 256, Math.random() * 256]);
let [r, g, b] = hex_to_rgb(k_color);
let [h, s, l] = rgb_to_hsl(r, g, b);
let theme1 = {
l5: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 4.7)),
l4: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 4)),
l3: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 3)),
l2: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 2)),
l1: rgb_to_hex(hsl_to_rgb(h, s, l + ((1.0 - l) / 5) * 1)),
d0: rgb_to_hex(hsl_to_rgb(h, s, l)),
d1: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 0.5)),
d2: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 1)),
d3: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 1.5)),
d4: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 2)),
d5: rgb_to_hex(hsl_to_rgb(h, s, l - (l / 5) * 2.5)),
};
for (let [k, v] of Object.entries(theme1)) {
theme1['t' + k] = is_dark(v, 165) ? '#fff' : '#000';
}
let result = `
.w3-theme-l5 {color: ${theme1.tl5} !important; background-color: ${theme1.l5} !important}
.w3-theme-l4 {color: ${theme1.tl4} !important; background-color: ${theme1.l4} !important}
.w3-theme-l3 {color: ${theme1.tl3} !important; background-color: ${theme1.l3} !important}
.w3-theme-l2 {color: ${theme1.tl2} !important; background-color: ${theme1.l2} !important}
.w3-theme-l1 {color: ${theme1.tl1} !important; background-color: ${theme1.l1} !important}
.w3-theme-d1 {color: ${theme1.td1} !important; background-color: ${theme1.d1} !important}
.w3-theme-d2 {color: ${theme1.td2} !important; background-color: ${theme1.d2} !important}
.w3-theme-d3 {color: ${theme1.td3} !important; background-color: ${theme1.d3} !important}
.w3-theme-d4 {color: ${theme1.td4} !important; background-color: ${theme1.d4} !important}
.w3-theme-d5 {color: ${theme1.td5} !important; background-color: ${theme1.d5} !important}
.w3-theme-light {color: ${theme1.tl5} !important; background-color: ${theme1.l5} !important}
.w3-theme-dark {color: ${theme1.td5} !important; background-color: ${theme1.d5} !important}
.w3-theme-action {color: ${theme1.td5} !important; background-color: ${theme1.d5} !important}
.w3-theme {color: ${theme1.td0} !important; background-color: ${theme1.d0} !important}
.w3-text-theme {color: ${theme1.d0} !important}
.w3-border-theme {border-color: ${theme1.d0} !important}
.w3-hover-theme:hover {color: ${theme1.td0} !important; background-color: ${theme1.d0} !important}
.w3-hover-text-theme:hover {color: ${theme1.d0} !important}
.w3-hover-border-theme:hover {border-color: ${theme1.d0} !important}
`;
return unsafeCSS(result);
}
export let styles = [tf, w3, generated()];

View File

@ -7,55 +7,35 @@ class TfTabConnectionsElement extends LitElement {
return { return {
broadcasts: {type: Array}, broadcasts: {type: Array},
identities: {type: Array}, identities: {type: Array},
my_identities: {type: Array},
connections: {type: Array}, connections: {type: Array},
stored_connections: {type: Array}, stored_connections: {type: Array},
users: {type: Object}, users: {type: Object},
server_identity: {type: String},
connect_attempt: {type: Object},
connect_message: {type: String},
connect_success: {type: Boolean},
}; };
} }
static styles = styles; static styles = styles;
static k_broadcast_emojis = {
discovery: '🏓',
room: '🚪',
peer_exchange: '🕸',
};
constructor() { constructor() {
super(); super();
let self = this; let self = this;
this.broadcasts = []; this.broadcasts = [];
this.identities = []; this.identities = [];
this.my_identities = [];
this.connections = []; this.connections = [];
this.stored_connections = []; this.stored_connections = [];
this.users = {}; this.users = {};
tfrpc.rpc.getIdentities().then(function (identities) { tfrpc.rpc.getAllIdentities().then(function(identities) {
self.my_identities = identities || [];
});
tfrpc.rpc.getAllIdentities().then(function (identities) {
self.identities = identities || []; self.identities = identities || [];
}); });
tfrpc.rpc.getStoredConnections().then(function (connections) { tfrpc.rpc.getStoredConnections().then(function(connections) {
self.stored_connections = connections || []; self.stored_connections = connections || [];
}); });
tfrpc.rpc.getServerIdentity().then(function (identity) {
self.server_identity = identity;
});
} }
render_connection_summary(connection) { render_connection_summary(connection) {
if (connection.address && connection.port) { if (connection.address && connection.port) {
return html`<div> return html`(<small>${connection.address}:${connection.port}</small>)`;
<small>${connection.address}:${connection.port}</small>
</div>`;
} else if (connection.tunnel) { } else if (connection.tunnel) {
return html`<div>room peer</div>`; return html`(room peer)`;
} else { } else {
return JSON.stringify(connection); return JSON.stringify(connection);
} }
@ -63,12 +43,10 @@ class TfTabConnectionsElement extends LitElement {
render_room_peers(connection) { render_room_peers(connection) {
let self = this; let self = this;
let peers = this.broadcasts.filter((x) => x.tunnel?.id == connection); let peers = this.broadcasts.filter(x => x.tunnel?.id == connection);
if (peers.length) { if (peers.length) {
let connections = this.connections.map((x) => x.id); let connections = this.connections.map(x => x.id);
return html`${peers return html`${peers.filter(x => connections.indexOf(x.pubkey) == -1).map(x => html`${self.render_room_peer(x)}`)}`;
.filter((x) => connections.indexOf(x.pubkey) == -1)
.map((x) => html`${self.render_room_peer(x)}`)}`;
} }
} }
@ -80,47 +58,18 @@ class TfTabConnectionsElement extends LitElement {
let self = this; let self = this;
return html` return html`
<li> <li>
<button <button class="w3-button w3-dark-grey" @click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)}>Connect</button>
class="w3-button w3-theme-d1"
@click=${() => self._tunnel(connection.tunnel.id, connection.pubkey)}
>
Connect
</button>
<tf-user id=${connection.pubkey} .users=${this.users}></tf-user> 📡 <tf-user id=${connection.pubkey} .users=${this.users}></tf-user> 📡
</li> </li>
`; `;
} }
render_message(connection) {
return html`<div
?hidden=${this.connect_message === undefined ||
this.connect_attempt != connection}
style="cursor: pointer"
class=${'w3-panel ' + (this.connect_success ? 'w3-green' : 'w3-red')}
@click=${() => (this.connect_attempt = undefined)}
>
<p>${this.connect_message}</p>
</div>`;
}
render_broadcast(connection) { render_broadcast(connection) {
let self = this;
return html` return html`
<li> <li>
<div class="w3-bar" style="overflow: hidden; overflow-wrap: nowrap"> <button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(connection)}>Connect</button>
<button <tf-user id=${connection.pubkey} .users=${this.users}></tf-user>
class="w3-bar-item w3-button w3-theme-d1" ${this.render_connection_summary(connection)}
@click=${() => self.connect(connection)}
>
Connect
</button>
<div class="w3-bar-item">
${TfTabConnectionsElement.k_broadcast_emojis[connection.origin]}
<tf-user id=${connection.pubkey} .users=${this.users}></tf-user>
${this.render_connection_summary(connection)}
</div>
</div>
${this.render_message(connection)}
</li> </li>
`; `;
} }
@ -131,164 +80,48 @@ class TfTabConnectionsElement extends LitElement {
} }
render_connection(connection) { render_connection(connection) {
let requests = Object.values(
connection.requests.reduce(function (accumulator, value) {
let key = `${value.name}:${Math.sign(value.request_number)}`;
if (!accumulator[key]) {
accumulator[key] = Object.assign({count: 0}, value);
}
accumulator[key].count++;
return accumulator;
}, {})
);
return html` return html`
${connection.connected <button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.closeConnection(connection.id)}>Close</button>
? html`
<button
class="w3-button w3-theme-d1"
@click=${() => tfrpc.rpc.closeConnection(connection.id)}
>
Close
</button>
`
: undefined}
${connection.flags.one_shot ? '🔃' : undefined}
<tf-user id=${connection.id} .users=${this.users}></tf-user> <tf-user id=${connection.id} .users=${this.users}></tf-user>
${connection.tunnel !== undefined ${connection.tunnel !== undefined ? '🚇' : html`(${connection.host}:${connection.port})`}
? '🚇'
: html`(${connection.host}:${connection.port})`}
<div>
${requests.map(
(x) => html`
<span
class=${'w3-tag w3-small ' +
(x.active ? 'w3-theme-l3' : 'w3-theme-d3')}
>${x.request_number > 0 ? '🟩' : '🟥'} ${x.name}
<span
class="w3-badge w3-white"
style=${x.count > 1 ? undefined : 'display: none'}
>${x.count}</span
></span
>
`
)}
</div>
<ul> <ul>
${this.connections ${this.connections.filter(x => x.tunnel === this.connections.indexOf(connection)).map(x => html`<li>${this.render_connection(x)}</li>`)}
.filter((x) => x.tunnel === this.connections.indexOf(connection))
.map((x) => html`<li>${this.render_connection(x)}</li>`)}
${this.render_room_peers(connection.id)} ${this.render_room_peers(connection.id)}
</ul> </ul>
<div ?hidden=${!connection.destroy_reason} class="w3-panel w3-red">
<p>${connection.destroy_reason}</p>
</div>
`; `;
} }
connect(address) {
let self = this;
self.connect_attempt = address;
self.connect_message = undefined;
self.connect_success = false;
tfrpc.rpc
.connect(address)
.then(function () {
if (self.connect_attempt == address) {
self.connect_message = 'Connected.';
self.connect_success = true;
}
})
.catch(function (error) {
if (self.connect_attempt == address) {
self.connect_message = 'Error: ' + error;
self.connect_success = false;
}
});
}
render() { render() {
let self = this; let self = this;
return html` return html`
<div class="w3-container" style="box-sizing: border-box"> <div class="w3-container">
<h2>New Connection</h2> <h2>New Connection</h2>
<textarea class="w3-input w3-theme-d1" id="code"></textarea> <textarea class="w3-input w3-dark-grey" id="code"></textarea>
${this.render_message(this.renderRoot.getElementById('code')?.value)} <button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(self.renderRoot.getElementById('code').value)}>Connect</button>
<button
class="w3-button w3-theme-d1"
@click=${() =>
self.connect(self.renderRoot.getElementById('code')?.value)}
>
Connect
</button>
<h2>Broadcasts</h2> <h2>Broadcasts</h2>
<ul class="w3-ul w3-border"> <ul>
${this.broadcasts ${this.broadcasts.filter(x => x.address).map(x => self.render_broadcast(x))}
.filter((x) => x.address)
.filter(
(x) => self.connections.map((c) => c.id).indexOf(x.pubkey) == -1
)
.map((x) => self.render_broadcast(x))}
</ul> </ul>
<h2>Connections</h2> <h2>Connections</h2>
<ul class="w3-ul w3-border"> <ul>
${this.connections ${this.connections.filter(x => x.tunnel === undefined).map(x => html`
.filter((x) => x.tunnel === undefined) <li>${this.render_connection(x)}</li>
.map( `)}
(x) => html`
<li class="w3-bar">${this.render_connection(x)}</li>
`
)}
</ul> </ul>
<h2>Stored Connections</h2> <h2>Stored Connections (WIP)</h2>
<ul class="w3-ul w3-border"> <ul>
${this.stored_connections.map( ${this.stored_connections.map(x => html`
(x) => html` <li>
<li> <button class="w3-button w3-dark-grey" @click=${() => self.forget_stored_connection(x)}>Forget</button>
<div class="w3-bar"> <button class="w3-button w3-dark-grey" @click=${() => tfrpc.rpc.connect(x)}>Connect</button>
<button ${x.address}:${x.port} <tf-user id=${x.pubkey} .users=${self.users}></tf-user>
class="w3-bar-item w3-button w3-theme-d1" </li>
@click=${() => self.forget_stored_connection(x)} `)}
>
Forget
</button>
<button
class="w3-bar-item w3-button w3-theme-d1"
@click=${() => this.connect(x)}
>
Connect
</button>
<div class="w3-bar-item">
<tf-user id=${x.pubkey} .users=${self.users}></tf-user>
<div><small>${x.address}:${x.port}</small></div>
</div>
</div>
${this.render_message(x)}
</li>
`
)}
</ul> </ul>
<h2>Local Accounts</h2> <h2>Local Accounts</h2>
<div class="w3-container"> <ul>
${this.identities.map( ${this.identities.map(x => html`<li><tf-user id=${x} .users=${this.users}></tf-user></li>`)}
(x) => </ul>
html`<div
class="w3-tag w3-round w3-theme-l3"
style="padding: 4px; margin: 2px; max-width: 100%; text-wrap: nowrap; overflow: hidden"
>
${x == this.server_identity
? html`<div class="w3-tag w3-medium w3-round w3-theme-l1">
🖥 local server
</div>`
: undefined}
${this.my_identities.indexOf(x) != -1
? html`<div class="w3-tag w3-medium w3-round w3-theme-d1">
😎 you
</div>`
: undefined}
<tf-user id=${x} .users=${this.users}></tf-user>
</div>`
)}
</div>
</div> </div>
`; `;
} }

View File

@ -0,0 +1,65 @@
import {LitElement, html, unsafeHTML} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js';
import {styles} from './tf-styles.js';
class TfTabMentionsElement extends LitElement {
static get properties() {
return {
whoami: {type: String},
users: {type: Object},
following: {type: Array},
expanded: {type: Object},
messages: {type: Array},
};
}
static styles = styles;
constructor() {
super();
let self = this;
this.whoami = null;
this.users = {};
this.following = [];
this.expanded = {};
this.messages = [];
}
async load() {
console.log('Loading...', this.whoami);
let results = await tfrpc.rpc.query(`
SELECT messages.*
FROM messages_fts(?)
JOIN messages ON messages.rowid = messages_fts.rowid
JOIN json_each(?) AS following ON messages.author = following.value
WHERE messages.author != ?
ORDER BY timestamp DESC limit 20
`,
['"' + this.whoami.replace('"', '""') + '"', JSON.stringify(this.following), this.whoami]);
console.log('Done.');
this.messages = results;
}
on_expand(event) {
if (event.detail.expanded) {
let expand = {};
expand[event.detail.id] = true;
this.expanded = Object.assign({}, this.expanded, expand);
} else {
delete this.expanded[event.detail.id];
this.expanded = Object.assign({}, this.expanded);
}
}
render() {
let self = this;
if (!this.loading) {
this.loading = true;
this.load();
}
return html`
<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users} .expanded=${this.expanded} @tf-expand=${this.on_expand}></tf-news>
`;
}
}
customElements.define('tf-tab-mentions', TfTabMentionsElement);

View File

@ -1,4 +1,4 @@
import {LitElement, cache, html, unsafeHTML, until} from './lit-all.min.js'; import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js'; import * as tfrpc from '/static/tfrpc.js';
import {styles} from './tf-styles.js'; import {styles} from './tf-styles.js';
@ -12,11 +12,6 @@ class TfTabNewsFeedElement extends LitElement {
messages: {type: Array}, messages: {type: Array},
drafts: {type: Object}, drafts: {type: Object},
expanded: {type: Object}, expanded: {type: Object},
channels_unread: {type: Object},
channels_latest: {type: Object},
loading: {type: Number},
time_range: {type: Array},
time_loading: {type: Array},
}; };
} }
@ -31,232 +26,127 @@ class TfTabNewsFeedElement extends LitElement {
this.following = []; this.following = [];
this.drafts = {}; this.drafts = {};
this.expanded = {}; this.expanded = {};
this.channels_unread = {}; this.start_time = new Date().valueOf() - 24 * 60 * 60 * 1000;
this.channels_latest = {};
this.start_time = new Date().valueOf();
this.time_range = [0, 0];
this.time_loading = undefined;
this.loading = 0;
} }
channel() { async fetch_messages() {
return this.hash.startsWith('##') if (this.hash.startsWith('#@')) {
? this.hash.substring(2) let r = await tfrpc.rpc.query(
: this.hash.substring(1);
}
async fetch_messages(start_time, end_time) {
console.log('fetch_messages', this.hash, start_time, end_time);
this.time_loading = [start_time, end_time];
let result;
if (this.hash == '#@') {
result = await tfrpc.rpc.query(
` `
WITH mentions AS (SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature WITH mine AS (SELECT messages.*
FROM messages_fts(?1) FROM messages
JOIN messages ON messages.rowid = messages_fts.rowid WHERE messages.author = ?
JOIN json_each(?2) AS following ON messages.author = following.value ORDER BY sequence DESC
WHERE LIMIT 20)
messages.author != ?1 AND SELECT messages.*
?3 IS NULL OR (messages.timestamp >= ?3 AND messages.timestamp < ?4) FROM mine
ORDER BY timestamp DESC limit 20) JOIN messages_refs ON mine.id = messages_refs.ref
SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
FROM mentions
JOIN messages_refs ON mentions.id = messages_refs.ref
JOIN messages ON messages_refs.message = messages.id JOIN messages ON messages_refs.message = messages.id
UNION UNION
SELECT TRUE AS is_primary, * FROM mentions SELECT * FROM mine
`, `,
[ [
'"' + this.whoami.replace('"', '""') + '"', this.hash.substring(1),
JSON.stringify(this.following), ]);
start_time, return r;
end_time,
]
);
} else if (this.hash.startsWith('#@')) {
result = await tfrpc.rpc.query(
`
WITH
mine AS (SELECT rowid, id, previous, author, sequence, timestamp, hash, json(content) AS content, signature
FROM messages
WHERE messages.author = ?),
selected AS (
SELECT * FROM mine
WHERE ?2 IS NULL OR (mine.timestamp >= 2 AND mine.timestamp < ?3)
ORDER BY sequence DESC LIMIT 20
)
SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
FROM selected
JOIN messages_refs ON selected.id = messages_refs.ref
JOIN messages ON messages_refs.message = messages.id
UNION
SELECT TRUE AS is_primary, * FROM selected
`,
[this.hash.substring(1), start_time, end_time]
);
} else if (this.hash.startsWith('#%')) { } else if (this.hash.startsWith('#%')) {
result = await tfrpc.rpc.query( return await tfrpc.rpc.query(
` `
SELECT TRUE AS is_primary, id, previous, author, sequence, timestamp, hash, json(content) AS content, signature SELECT messages.*
FROM messages FROM messages
WHERE messages.id = ?1 WHERE id = ?1
UNION UNION
SELECT FALSE AS is_primary, id, previous, author, sequence, timestamp, hash, json(content) AS content, signature SELECT messages.*
FROM messages JOIN messages_refs FROM messages JOIN messages_refs
ON messages.id = messages_refs.message ON messages.id = messages_refs.message
WHERE messages_refs.ref = ?1 WHERE messages_refs.ref = ?1
`, `,
[this.hash.substring(1)] [
); this.hash.substring(1),
} else if (this.hash.startsWith('##')) { ]);
} else {
let promises = []; let promises = [];
const k_following_limit = 256; const k_following_limit = 256;
for (let i = 0; i < this.following.length; i += k_following_limit) { for (let i = 0; i < this.following.length; i += k_following_limit) {
promises.push( promises.push(tfrpc.rpc.query(
tfrpc.rpc.query( `
` WITH news AS (SELECT messages.*
WITH FROM messages
all_news AS ( JOIN json_each(?) AS following ON messages.author = following.value
SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature WHERE messages.timestamp > ? AND messages.timestamp < ?
FROM messages ORDER BY messages.timestamp DESC)
JOIN json_each(?) AS following ON messages.author = following.value SELECT messages.*
WHERE messages.content ->> 'channel' = ?4
UNION
SELECT messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
FROM messages_fts(?5)
JOIN messages ON messages.rowid = messages_fts.rowid
JOIN json_each(?1) AS following ON messages.author = following.value
JOIN json_tree(messages.content, '$.mentions') AS mention ON mention.value = '#' || ?4),
news AS (SELECT * FROM all_news
WHERE ?2 IS NULL OR (all_news.timestamp >= ?2 AND all_news.timestamp < ?3)
ORDER BY all_news.timestamp DESC LIMIT 20)
SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
FROM news FROM news
JOIN messages_refs ON news.id = messages_refs.ref JOIN messages_refs ON news.id = messages_refs.ref
JOIN messages ON messages_refs.message = messages.id JOIN messages ON messages_refs.message = messages.id
UNION UNION
SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature SELECT messages.*
FROM news FROM news
JOIN messages_refs ON news.id = messages_refs.message JOIN messages_refs ON news.id = messages_refs.message
JOIN messages ON messages_refs.ref = messages.id JOIN messages ON messages_refs.ref = messages.id
UNION UNION
SELECT TRUE AS is_primary, news.* FROM news SELECT news.* FROM news
`, `,
[ [
JSON.stringify(this.following.slice(i, i + k_following_limit)), JSON.stringify(this.following.slice(i, i + k_following_limit)),
start_time, this.start_time,
end_time, /*
this.hash.substring(2), ** Don't show messages more than a day into the future to prevent
'"#' + this.hash.substring(2).replace('"', '""') + '"', ** messages with far-future timestamps from staying at the top forever.
] */
) new Date().valueOf() + 24 * 60 * 60 * 1000,
); ]));
} }
result = [].concat(...(await Promise.all(promises))); return [].concat(...(await Promise.all(promises)));
} else if (this.hash == '#🔐') { }
result = await tfrpc.rpc.query( }
`
SELECT TRUE AS is_primary, messages.rowid, messages.id, previous, author, sequence, timestamp, hash, json(content) AS content, signature async load_more() {
FROM messages let last_start_time = this.start_time;
JOIN json_each(?1) AS following ON messages.author = following.value this.start_time = last_start_time - 24 * 60 * 60 * 1000;
WHERE let more = await tfrpc.rpc.query(
?2 IS NULL OR (messages.timestamp >= ?2 AND messages.timestamp < ?3) AND `
json(messages.content) LIKE '"%' WITH news AS (SELECT messages.*
ORDER BY sequence DESC LIMIT 20 FROM messages
`, JOIN json_each(?) AS following ON messages.author = following.value
[JSON.stringify(this.following), start_time, end_time] WHERE messages.timestamp > ?
); AND messages.timestamp <= ?
result = (await this.decrypt(result)).filter((x) => x.decrypted); ORDER BY messages.timestamp DESC)
} else { SELECT messages.*
result = await tfrpc.rpc.query(
`
WITH
all_news AS (
SELECT messages.rowid, 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 timestamp >= 0 AND timestamp < ?3),
news AS (
SELECT * FROM all_news
WHERE ?2 IS NULL or (all_news.timestamp >= ?2 AND all_news.timestamp < ?3)
ORDER BY timestamp DESC LIMIT 20
)
SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
FROM news FROM news
JOIN messages_refs ON news.id = messages_refs.ref JOIN messages_refs ON news.id = messages_refs.ref
JOIN messages ON messages_refs.message = messages.id JOIN messages ON messages_refs.message = messages.id
UNION UNION
SELECT FALSE AS is_primary, messages.rowid, messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature SELECT messages.*
FROM news FROM news
JOIN messages_refs ON news.id = messages_refs.message JOIN messages_refs ON news.id = messages_refs.message
JOIN messages ON messages_refs.ref = messages.id JOIN messages ON messages_refs.ref = messages.id
UNION UNION
SELECT TRUE AS is_primary, news.* FROM news SELECT news.* FROM news
`, `,
[ [
JSON.stringify(this.following), JSON.stringify(this.following),
start_time, this.start_time,
end_time, last_start_time,
] ]);
); this.messages = await this.decrypt([...more, ...this.messages]);
}
this.time_loading = undefined;
return result;
}
update_time_range_from_messages(messages) {
let only_primary = messages.filter((x) => x.is_primary);
this.time_range = [
only_primary.reduce(
(accumulator, current) => Math.min(accumulator, current.timestamp),
this.time_range[0]
),
only_primary.reduce(
(accumulator, current) => Math.max(accumulator, current.timestamp),
this.time_range[1]
),
];
}
async load_more() {
this.loading++;
this.loading_canceled = false;
try {
let more = [];
while (!more.length && !this.loading_canceled) {
let last_start_time = this.start_time;
this.start_time = last_start_time - 7 * 24 * 60 * 60 * 1000;
more = await this.fetch_messages(this.start_time, last_start_time);
this.update_time_range_from_messages(
more.filter(
(x) =>
x.timestamp >= this.start_time && x.timestamp < last_start_time
)
);
}
this.messages = await this.decrypt([...more, ...this.messages]);
} finally {
this.loading--;
}
}
cancel_load() {
this.loading_canceled = true;
} }
async decrypt(messages) { async decrypt(messages) {
console.log('decrypt');
let result = []; let result = [];
for (let message of messages) { for (let message of messages) {
let content; let content;
try { try {
content = JSON.parse(message?.content); content = JSON.parse(message?.content);
} catch {} } catch {
if (typeof content === 'string') { }
if (typeof(content) === 'string') {
let decrypted; let decrypted;
try { try {
decrypted = await tfrpc.rpc.try_decrypt(this.whoami, content); decrypted = await tfrpc.rpc.try_decrypt(this.whoami, content);
} catch {} } catch {
}
if (decrypted) { if (decrypted) {
try { try {
message.decrypted = JSON.parse(decrypted); message.decrypted = JSON.parse(decrypted);
@ -270,162 +160,38 @@ class TfTabNewsFeedElement extends LitElement {
return result; return result;
} }
merge_messages(old_messages, new_messages) { async add_messages(messages) {
let old_by_id = Object.fromEntries(old_messages.map((x) => [x.id, x])); this.messages = await this.decrypt([...messages, ...this.messages]);
return new_messages.map((x) => (old_by_id[x.id] ? old_by_id[x.id] : x));
}
async load_latest() {
this.loading++;
let now = new Date().valueOf();
let end_time = now + 24 * 60 * 60 * 1000;
let messages = [];
try {
messages = await this.fetch_messages(
this.time_range[1] - 24 * 60 * 60 * 1000,
end_time
);
messages = await this.decrypt(messages);
this.update_time_range_from_messages(
messages.filter(
(x) => x.timestamp >= this.time_range[1] && x.timestamp < end_time
)
);
} finally {
this.loading--;
}
this.messages = this.merge_messages(
this.messages,
Object.values(
Object.fromEntries(
[...this.messages, ...messages]
.sort((x, y) => x.timestamp - y.timestamp)
.slice(-1024)
.map((x) => [x.id, x])
)
)
);
console.log('done loading latest messages.');
}
async load_messages() {
let start_time = new Date();
let self = this;
this.loading++;
let messages = [];
try {
if (this._messages_hash !== this.hash) {
this.messages = [];
this._messages_hash = this.hash;
}
this._messages_following = this.following;
let now = new Date().valueOf();
let start_time = now - 24 * 60 * 60 * 1000;
this.start_time = start_time;
this.time_range = [this.start_time, now + 24 * 60 * 60 * 1000];
messages = await this.fetch_messages(
null,
this.time_range[1]
);
this.update_time_range_from_messages(
messages.filter(
(x) => x.timestamp < this.time_range[1]
)
);
messages = await this.decrypt(messages);
} finally {
this.loading--;
}
this.messages = this.merge_messages(this.messages, messages);
this.time_loading = undefined;
console.log(`loading messages done for ${self.whoami} in ${(new Date() - start_time) / 1000}s`);
}
mark_all_read() {
let newest = this.messages.reduce(
(accumulator, current) => Math.max(accumulator, current.rowid),
this.channels_latest[this.channel()] ?? -1
);
if (newest >= 0) {
this.dispatchEvent(
new CustomEvent('channelsetunread', {
bubbles: true,
composed: true,
detail: {
channel: this.channel(),
unread: newest + 1,
},
})
);
}
} }
render() { render() {
if ( if (!this.messages ||
!this.messages ||
this._messages_hash !== this.hash || this._messages_hash !== this.hash ||
JSON.stringify(this._messages_following) !== this._messages_following !== this.following) {
JSON.stringify(this.following) console.log(`loading messages for ${this.whoami} (following ${this.following.length})`);
) { let self = this;
console.log( this.messages = [];
`loading messages for ${this.whoami} (following ${this.following.length})` this._messages_hash = this.hash;
); this._messages_following = this.following;
this.load_messages(); 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; let more;
if (!this.hash.startsWith('#%')) { if (!this.hash.startsWith('#@') && !this.hash.startsWith('#%')) {
more = html` more = html`
<p> <p>
<button class="w3-button w3-theme-d1" @click=${this.mark_all_read}> <button class="w3-button w3-dark-grey" @click=${this.load_more}>Load More</button>
Mark All Read
</button>
<button
?disabled=${this.loading}
class="w3-button w3-theme-d1"
@click=${this.load_more}
>
Load More
</button>
<button
class=${'w3-button w3-theme-d1' + (this.loading ? '' : ' w3-hide')}
@click=${this.cancel_load}
>
Cancel
</button>
<span
>Showing
${new Date(
this.time_loading
? Math.min(this.time_loading[0], this.time_range[0])
: this.time_range[0]
).toLocaleDateString()}
-
${new Date(
this.time_loading
? Math.max(this.time_loading[1], this.time_range[1])
: this.time_range[1]
).toLocaleDateString()}.</span
>
</p> </p>
`; `;
} }
return cache(html` return html`
<button class="w3-button w3-theme-d1" @click=${this.mark_all_read}> <tf-news id="news" whoami=${this.whoami} .users=${this.users} .messages=${this.messages} .following=${this.following} .drafts=${this.drafts} .expanded=${this.expanded}></tf-news>
Mark All Read
</button>
<tf-news
id="news"
whoami=${this.whoami}
.users=${this.users}
.messages=${this.messages}
.following=${this.following}
.drafts=${this.drafts}
.expanded=${this.expanded}
channel=${this.channel()}
channel_unread=${this.channels_unread?.[this.channel()]}
></tf-news>
${more} ${more}
`); `;
} }
} }

View File

@ -1,4 +1,4 @@
import {LitElement, cache, html, unsafeHTML, until} from './lit-all.min.js'; import {LitElement, html, unsafeHTML, until} from './lit-all.min.js';
import * as tfrpc from '/static/tfrpc.js'; import * as tfrpc from '/static/tfrpc.js';
import {styles} from './tf-styles.js'; import {styles} from './tf-styles.js';
@ -8,14 +8,10 @@ class TfTabNewsElement extends LitElement {
whoami: {type: String}, whoami: {type: String},
users: {type: Object}, users: {type: Object},
hash: {type: String}, hash: {type: String},
unread: {type: Array},
following: {type: Array}, following: {type: Array},
drafts: {type: Object}, drafts: {type: Object},
expanded: {type: Object}, expanded: {type: Object},
loading: {type: Boolean},
channels: {type: Array},
channels_unread: {type: Object},
channels_latest: {type: Object},
connections: {type: Array},
}; };
} }
@ -27,15 +23,12 @@ class TfTabNewsElement extends LitElement {
this.whoami = null; this.whoami = null;
this.users = {}; this.users = {};
this.hash = '#'; this.hash = '#';
this.unread = [];
this.following = []; this.following = [];
this.cache = {}; this.cache = {};
this.drafts = {}; this.drafts = {};
this.expanded = {}; this.expanded = {};
this.channels_unread = {}; tfrpc.rpc.localStorageGet('drafts').then(function(d) {
this.channels_latest = {};
this.channels = [];
this.connections = [];
tfrpc.rpc.localStorageGet('drafts').then(function (d) {
self.drafts = JSON.parse(d || '{}'); self.drafts = JSON.parse(d || '{}');
}); });
} }
@ -50,13 +43,32 @@ class TfTabNewsElement extends LitElement {
document.body.removeEventListener('keypress', this.on_keypress.bind(this)); document.body.removeEventListener('keypress', this.on_keypress.bind(this));
} }
load_latest() { show_more() {
let unread = this.unread;
let news = this.shadowRoot?.getElementById('news'); let news = this.shadowRoot?.getElementById('news');
if (news) { if (news) {
news.load_latest(); console.log('injecting messages', news.messages);
news.add_messages(Object.values(Object.fromEntries(this.unread.map(x => [x.id, x]))));
this.dispatchEvent(new CustomEvent('refresh'));
} }
} }
new_messages_text() {
if (!this.unread?.length) {
return 'No new messages.';
}
let counts = {};
for (let message of this.unread) {
let type = 'private';
try {
type = JSON.parse(message.content).type || type;
} catch {
}
counts[type] = (counts[type] || 0) + 1;
}
return '↻ Show New: ' + Object.keys(counts).sort().map(x => (counts[x].toString() + ' ' + x + 's')).join(', ');
}
draft(event) { draft(event) {
let id = event.detail.id || ''; let id = event.detail.id || '';
let previous = this.drafts[id]; let previous = this.drafts[id];
@ -65,7 +77,10 @@ class TfTabNewsElement extends LitElement {
} else { } else {
delete this.drafts[id]; delete this.drafts[id];
} }
this.drafts = Object.assign({}, this.drafts); /* Only trigger a re-render if we're creating a new draft or discarding an old one. */
if ((previous !== undefined) != (event.detail.draft !== undefined)) {
this.drafts = Object.assign({}, this.drafts);
}
tfrpc.rpc.localStorageSet('drafts', JSON.stringify(this.drafts)); tfrpc.rpc.localStorageSet('drafts', JSON.stringify(this.drafts));
} }
@ -81,256 +96,24 @@ class TfTabNewsElement extends LitElement {
} }
on_keypress(event) { on_keypress(event) {
if (event.target === document.body && event.key == '.') { if (event.target === document.body &&
event.key == '.') {
this.show_more(); this.show_more();
} }
} }
unread_status(channel) {
if (
this.channels_latest[channel] &&
this.channels_latest[channel] > 0 &&
(this.channels_unread[channel] === undefined ||
this.channels_unread[channel] <= this.channels_latest[channel])
) {
return '✉️ ';
}
}
show_sidebar() {
this.renderRoot.getElementById('sidebar').style.display = 'block';
this.renderRoot.getElementById('sidebar_overlay').style.display = 'block';
}
hide_sidebar() {
this.renderRoot.getElementById('sidebar').style.display = 'none';
this.renderRoot.getElementById('sidebar_overlay').style.display = 'none';
}
async channel_toggle_subscribed() {
let channel = this.hash.substring(2);
let subscribed = this.channels.indexOf(channel) != -1;
subscribed = !subscribed;
await tfrpc.rpc.appendMessage(this.whoami, {
type: 'channel',
channel: channel,
subscribed: subscribed,
});
if (subscribed) {
this.channels = [].concat([channel], this.channels).sort();
} else {
this.channels = this.channels.filter((x) => x != channel);
}
}
channel() {
return this.hash.startsWith('##') ? this.hash.substring(2) : undefined;
}
compare_follows() {
const now = new Date().valueOf();
return function (a, b) {
return (b[1].ts > now ? -1 : b[1].ts) - (a[1].ts > now ? -1 : a[1].ts);
};
}
suggested_follows() {
/*
** Filter out people who have used future timestamps so that they aren't
** pinned at the top.
*/
let self = this;
return Object.entries(this.users)
.filter((x) => x[1].follow_depth > 1)
.sort(self.compare_follows())
.slice(0, 8)
.map((x) => x[0]);
}
render_sidebar() {
return html`
<div
class="w3-sidebar w3-bar-block w3-theme-d1 w3-collapse w3-animate-left"
style="width: 2in; left: 0; z-index: 5; box-sizing: border-box; top: 0"
id="sidebar"
>
<div
class="w3-right w3-button w3-hide-large"
@click=${this.hide_sidebar}
>
&times;
</div>
${this.hash.startsWith('##') &&
this.channels.indexOf(this.hash.substring(2)) == -1
? html`
<div class="w3-bar-item w3-theme-d2">Viewing</div>
<a
href="#"
class="w3-bar-item w3-button"
style="font-weight: bold"
>${this.hash.substring(2)}</a
>
`
: undefined}
<h4 class="w3-bar-item w3-theme-d2">Channels</h4>
<a
href="#"
class="w3-bar-item w3-button"
style=${this.hash == '#' ? 'font-weight: bold' : undefined}
>${this.unread_status('')}general</a
>
<a
href="#@"
class="w3-bar-item w3-button"
style=${this.hash == '#@' ? 'font-weight: bold' : undefined}
>${this.unread_status('@')}@mentions</a
>
<a
href="#🔐"
class="w3-bar-item w3-button"
style=${this.hash == '#🔐' ? 'font-weight: bold' : undefined}
>${this.unread_status('🔐')}🔐private</a
>
${Object.keys(this.drafts)
.sort()
.map(
(x) => html`
<a
href=${'#' + encodeURIComponent(x)}
class="w3-bar-item w3-button"
style="text-wrap: nowrap; text-overflow: ellipsis"
>📝 ${this.drafts[x]?.text ?? x}</a
>
`
)}
${this.channels.map(
(x) => html`
<a
href=${'#' + encodeURIComponent('#' + x)}
class="w3-bar-item w3-button"
style=${this.hash == '##' + x ? 'font-weight: bold' : undefined}
>${this.unread_status(x)}#${x}</a
>
`
)}
<h4 class="w3-bar-item w3-theme-d2">Connections</h4>
${this.connections
.filter((x) => x.id && !x.destroy_reason)
.map(
(x) => html`
<tf-user
class="w3-bar-item"
style="max-width: 100%"
id=${x.id}
.users=${this.users}
></tf-user>
`
)}
<h4 class="w3-bar-item w3-theme-d2">Suggested Follows</h4>
${this.suggested_follows().map(
(x) => html`
<tf-user
class="w3-bar-item"
style="max-width: 100%"
id=${x}
.users=${this.users}
></tf-user>
`
)}
</div>
<div
class="w3-overlay"
id="sidebar_overlay"
@click=${this.hide_sidebar}
></div>
`;
}
render() { render() {
let profile = let profile = this.hash.startsWith('#@') ?
this.hash.startsWith('#@') && this.hash != '#@' html`<tf-profile id=${this.hash.substring(1)} whoami=${this.whoami} .users=${this.users}></tf-profile>` : undefined;
? html`<tf-profile return html`
class="tf-profile" <p class="w3-bar">
id=${this.hash.substring(1)} <button class="w3-bar-item w3-button w3-dark-grey" @click=${this.show_more}>${this.new_messages_text()}</button>
whoami=${this.whoami} </p>
.users=${this.users} <div>Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!</div>
></tf-profile>` <div><tf-compose id="tf-compose" whoami=${this.whoami} .users=${this.users} .drafts=${this.drafts} @tf-draft=${this.draft}></tf-compose></div>
: undefined; ${profile}
let edit_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>
if ( `;
!this.loading &&
this.users[this.whoami]?.name === undefined &&
this.hash.substring(1) != this.whoami
) {
edit_profile = html` <div
class="w3-panel w3-padding w3-round w3-card-4 w3-theme-l3"
>
Follow your identity link above to edit your profile and set your
name.
</div>`;
}
return cache(html`
${this.render_sidebar()}
<div
style="margin-left: 2in; padding: 0px; top: 0; max-height: 100%; overflow: auto"
id="main"
class="w3-main"
>
<div style="padding: 8px">
<p>
${this.hash.startsWith('##')
? html`
<button
class="w3-button w3-theme-d1"
@click=${this.channel_toggle_subscribed}
>
${this.channels.indexOf(this.hash.substring(2)) != -1
? 'Unsubscribe from #'
: 'Subscribe to #'}${this.hash.substring(2)}
</button>
`
: undefined}
</p>
<div>
<div
id="show_sidebar"
class="w3-button w3-hide-large"
@click=${this.show_sidebar}
>
&#9776;
</div>
Welcome, <tf-user id=${this.whoami} .users=${this.users}></tf-user>!
${edit_profile}
</div>
<div>
<tf-compose
id="tf-compose"
whoami=${this.whoami}
.users=${this.users}
.drafts=${this.drafts}
@tf-draft=${this.draft}
.channel=${this.channel()}
></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}
.channels_unread=${this.channels_unread}
.channels_latest=${this.channels_latest}
></tf-tab-news-feed>
</div>
</div>
`);
} }
} }

View File

@ -41,7 +41,7 @@ class TfTabQueryElement extends LitElement {
await tfrpc.rpc.setHash('#sql=' + encodeURIComponent(query)); await tfrpc.rpc.setHash('#sql=' + encodeURIComponent(query));
let start_time = new Date(); let start_time = new Date();
try { try {
this.results = await tfrpc.rpc.query(query, []); this.results = await tfrpc.rpc.query(query, [])
} catch (error) { } catch (error) {
this.error = error; this.error = error;
} }
@ -79,15 +79,8 @@ class TfTabQueryElement extends LitElement {
} else { } else {
let keys = Object.keys(this.results[0]).sort(); let keys = Object.keys(this.results[0]).sort();
return html`<table style="width: 100%; max-width: 100%"> return html`<table style="width: 100%; max-width: 100%">
<tr> <tr>${keys.map(key => html`<th>${key}</th>`)}</tr>
${keys.map((key) => html`<th>${key}</th>`)} ${this.results.map(row => html`<tr>${keys.map(key => html`<td>${row[key]}</td>`)}</tr>`)}
</tr>
${this.results.map(
(row) =>
html`<tr>
${keys.map((key) => html`<td>${row[key]}</td>`)}
</tr>`
)}
</table>`; </table>`;
} }
} }
@ -107,28 +100,13 @@ class TfTabQueryElement extends LitElement {
let self = this; let self = this;
return html` return html`
<div style="display: flex; flex-direction: row; gap: 4px"> <div style="display: flex; flex-direction: row; gap: 4px">
<textarea <textarea id="search" rows=8 class="w3-input w3-dark-grey" style="flex: 1; resize: vertical" @keydown=${this.search_keydown}>${this.query}</textarea>
id="search" <button class="w3-button w3-dark-grey" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Execute</button>
rows="8"
class="w3-input w3-theme-d1"
style="flex: 1; resize: vertical"
@keydown=${this.search_keydown}
>
${this.query}</textarea
>
<button
class="w3-button w3-theme-d1"
@click=${(event) =>
self.search(self.renderRoot.getElementById('search').value)}
>
Execute
</button>
</div>
<div ?hidden=${this.duration === undefined}>
Took ${this.duration / 1000.0} seconds.
</div> </div>
<div ?hidden=${this.duration === undefined}>Took ${this.duration / 1000.0} seconds.</div>
<div ?hidden=${this.duration !== undefined}>Executing...</div> <div ?hidden=${this.duration !== undefined}>Executing...</div>
${this.render_error()} ${this.render_results()} ${this.render_error()}
${this.render_results()}
`; `;
} }
} }

View File

@ -5,7 +5,6 @@ import {styles} from './tf-styles.js';
class TfTabSearchElement extends LitElement { class TfTabSearchElement extends LitElement {
static get properties() { static get properties() {
return { return {
drafts: {type: Object},
whoami: {type: String}, whoami: {type: String},
users: {type: Object}, users: {type: Object},
following: {type: Array}, following: {type: Array},
@ -23,34 +22,28 @@ class TfTabSearchElement extends LitElement {
this.users = {}; this.users = {};
this.following = []; this.following = [];
this.expanded = {}; this.expanded = {};
this.drafts = {};
tfrpc.rpc.localStorageGet('drafts').then(function (d) {
self.drafts = JSON.parse(d || '{}');
});
} }
async search(query) { async search(query) {
console.log('Searching...', this.whoami, query); console.log('Searching...', this.whoami, query);
let search = this.renderRoot.getElementById('search'); let search = this.renderRoot.getElementById('search');
if (search) { if (search ) {
search.value = query; search.value = query;
search.focus(); search.focus();
search.select(); search.select();
} }
await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query)); await tfrpc.rpc.setHash('#q=' + encodeURIComponent(query));
let results = await tfrpc.rpc.query( let results = await tfrpc.rpc.query(`
` SELECT messages.*
SELECT messages.id, messages.previous, messages.author, messages.sequence, messages.timestamp, messages.hash, json(messages.content) AS content, messages.signature
FROM messages_fts(?) FROM messages_fts(?)
JOIN messages ON messages.rowid = messages_fts.rowid JOIN messages ON messages.rowid = messages_fts.rowid
JOIN json_each(?) AS following ON messages.author = following.value JOIN json_each(?) AS following ON messages.author = following.value
ORDER BY timestamp DESC limit 100 ORDER BY timestamp DESC limit 100
`, `,
['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)] ['"' + query.replace('"', '""') + '"', JSON.stringify(this.following)]);
);
console.log('Done.'); console.log('Done.');
search = this.renderRoot.getElementById('search'); search = this.renderRoot.getElementById('search');
if (search) { if (search ) {
search.value = query; search.value = query;
search.focus(); search.focus();
search.select(); search.select();
@ -75,18 +68,6 @@ class TfTabSearchElement extends LitElement {
} }
} }
draft(event) {
let id = event.detail.id || '';
let previous = this.drafts[id];
if (event.detail.draft !== undefined) {
this.drafts[id] = event.detail.draft;
} else {
delete this.drafts[id];
}
this.drafts = Object.assign({}, this.drafts);
tfrpc.rpc.localStorageSet('drafts', JSON.stringify(this.drafts));
}
render() { render() {
if (this.query !== this.last_query) { if (this.query !== this.last_query) {
this.last_query = this.query; this.last_query = this.query;
@ -95,10 +76,10 @@ class TfTabSearchElement extends LitElement {
let self = this; let self = this;
return html` return html`
<div style="display: flex; flex-direction: row; gap: 4px"> <div style="display: flex; flex-direction: row; gap: 4px">
<input type="text" class="w3-input w3-theme-d1" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input> <input type="text" class="w3-input w3-dark-grey" id="search" value=${this.query} style="flex: 1" @keydown=${this.search_keydown}></input>
<button class="w3-button w3-theme-d1" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Search</button> <button class="w3-button w3-dark-grey" @click=${(event) => self.search(self.renderRoot.getElementById('search').value)}>Search</button>
</div> </div>
<tf-news id="news" whoami=${this.whoami} .messages=${this.messages} .users=${this.users} .expanded=${this.expanded} .drafts=${this.drafts} @tf-expand=${this.on_expand} @tf-draft=${this.draft}></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>
`; `;
} }
} }

View File

@ -17,11 +17,7 @@ class TfTagElement extends LitElement {
render() { render() {
let number = this.count ? html` (${this.count})` : undefined; let number = this.count ? html` (${this.count})` : undefined;
return html`<a 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>`;
href=${'#' + encodeURIComponent(this.tag)}
class="w3-tag w3-theme-d1 w3-round-4 w3-button"
>${this.tag}${number}</a
> `;
} }
} }

View File

@ -19,35 +19,25 @@ class TfUserElement extends LitElement {
} }
render() { render() {
let user = this.users[this.id];
let shape = !user || user.follow_depth >= 2 ? 'w3-circle' : 'w3-round';
let image = html`<span
class=${'w3-theme-l4 ' + shape}
style="display: inline-block; width: 2em; height: 2em; text-align: center; line-height: 2em"
>😎</span
>`;
let name = this.users?.[this.id]?.name; let name = this.users?.[this.id]?.name;
name = html`<a target="_top" href=${'#' + this.id} name = name !== undefined ?
>${name !== undefined ? name : this.id}</a html`<a target="_top" href=${'#' + this.id}>${name}</a>` :
>`; html`<a target="_top" href=${'#' + this.id}>${this.id}</a>`;
if (user) { if (this.users[this.id]) {
let image_link = user.image; let image = this.users[this.id].image;
image_link = image = typeof(image) == 'string' ? image : image?.link;
typeof image_link == 'string' ? image_link : image_link?.link; return html`
if (image_link !== undefined) { <div style="display: inline-block; font-weight: bold">
image = html`<img <img style="width: 2em; height: 2em; vertical-align: middle; border-radius: 50%" ?hidden=${image === undefined} src="${image ? '/' + image + '/view' : undefined}">
class=${'w3-theme-l4 ' + shape} ${name}
style="width: 2em; height: 2em; vertical-align: middle; object-fit: cover" </div>`;
src="/${image_link}/view" } else {
/>`; return html`
} <div style="display: inline-block; font-weight: bold">
${name}
</div>`;
} }
return html` <div
style="display: inline-block; vertical-align: middle; font-weight: bold; text-wrap: nowrap; max-width: 100%; overflow: hidden; text-overflow: ellipsis"
>
${image} ${name}
</div>`;
} }
} }

Some files were not shown because too many files have changed in this diff Show More