├── .builds └── debian.yml ├── .github └── workflows │ └── issue-replication.yml ├── .gitignore ├── LICENSE.txt ├── Makefile ├── README.md ├── anim └── anim.go ├── appicon-release.png ├── appicon.png ├── chat.arbor.Client.Sprig.yml ├── connect-form-view.go ├── consent-view.go ├── core ├── app.go ├── arbor-service.go ├── banner-service.go ├── haptic-service.go ├── notification-service.go ├── settings-service.go ├── sprout-service.go ├── status-service.go └── theme-service.go ├── desktop-assets └── sprig.desktop ├── ds ├── community-list.go ├── reply-list.go └── trackers.go ├── dyn-reply-view.go ├── go.mod ├── go.sum ├── icons └── icons.go ├── identity-form.go ├── img └── screenshot.png ├── install-linux.sh ├── intent.go ├── magefile.go ├── main.go ├── platform ├── desktop.go └── mobile.go ├── platform_android.go ├── platform_other.go ├── reply-view.go ├── reply-view_desktop.go ├── reply-view_mobile.go ├── scripts └── macos-poll-and-build.sh ├── settings-view.go ├── sprig.app.template └── Contents │ ├── Info.plist │ ├── PkgInfo │ └── _CodeSignature │ └── CodeResources ├── subscription-setup-form.go ├── subscription-view.go ├── theme-editor.go ├── tools.go ├── version.go ├── view-manager.go ├── view.go └── widget ├── composer.go ├── message-list.go ├── polyclick.go ├── reply.go ├── text-form.go └── theme ├── composer.go ├── fonts └── static │ └── NotoEmoji-Regular.ttf ├── icon-button.go ├── message-list.go ├── reply.go ├── row.go ├── text-form.go ├── theme.go └── utils.go /.builds/debian.yml: -------------------------------------------------------------------------------- 1 | image: debian/testing 2 | packages: 3 | - curl 4 | - golang 5 | - zip 6 | - unzip 7 | - default-jdk-headless 8 | - pkg-config 9 | - libwayland-dev 10 | - libx11-dev 11 | - libx11-xcb-dev 12 | - libxkbcommon-x11-dev 13 | - libxcursor-dev 14 | - libgles2-mesa-dev 15 | - libegl1-mesa-dev 16 | - libffi-dev 17 | - libvulkan-dev 18 | secrets: 19 | - f5db0bff-87c2-4242-8c7e-59ba651d75ab 20 | - 536ae4e3-5a52-4d4f-a48c-daa63ed9819a 21 | - dfa34fc4-a789-4cbd-bfcf-edfe02a7eec0 22 | sources: 23 | - https://git.sr.ht/~whereswaldon/sprig 24 | environment: 25 | PATH: /usr/bin:/home/build/go/bin:/home/build/android/cmdline-tools/tools/bin 26 | ANDROID_HOME: /home/build/android 27 | ANDROID_SDK_ROOT: /home/build/android 28 | android_sdk_tools_zip: commandlinetools-linux-6200805_latest.zip 29 | android_ndk_zip: android-ndk-r20-linux-x86_64.zip 30 | android_target_platform: "platforms;android-31" 31 | android_target_build_tools: "build-tools;28.0.2" 32 | GO111MODULE: "on" 33 | github_mirror: git@github.com:arborchat/sprig 34 | triggers: 35 | - action: email 36 | condition: always 37 | to: ~whereswaldon/arbor-ci@lists.sr.ht 38 | tasks: 39 | - test: | 40 | cd sprig 41 | go test -v -cover ./... 42 | - mirror: | 43 | # mirror to github while we wait for android 44 | ssh-keyscan github.com > "$HOME"/.ssh/known_hosts && cd sprig && git push --mirror "$github_mirror" || echo "failed mirroring" 45 | - install_mage: go install github.com/magefile/mage@latest 46 | - build_windows: | 47 | cd sprig 48 | make windows 49 | - build_linux: | 50 | cd sprig 51 | make linux 52 | - install_android: | 53 | cd sprig 54 | if ! git describe --tags --exact-match HEAD; then exit 0; fi 55 | cd .. 56 | mkdir -p android/cmdline-tools 57 | cd android/cmdline-tools 58 | curl -so sdk-tools.zip "https://dl.google.com/android/repository/$android_sdk_tools_zip" 59 | unzip -q sdk-tools.zip 60 | rm sdk-tools.zip 61 | cd .. 62 | curl -so ndk.zip "https://dl.google.com/android/repository/$android_ndk_zip" 63 | unzip -q ndk.zip 64 | rm ndk.zip 65 | mv android-ndk-* ndk-bundle 66 | yes | sdkmanager --licenses 67 | sdkmanager "$android_target_platform" "$android_target_build_tools" 68 | - build_apk: | 69 | cd sprig 70 | if ! git describe --tags --exact-match HEAD; then exit 0; fi 71 | mv appicon-release.png appicon.png 72 | make APPID=chat.arbor.sprig sprig.apk 73 | - release: | 74 | cd sprig 75 | if ! git describe --tags --exact-match HEAD; then exit 0; fi 76 | tag=$(git describe --exact-match HEAD) 77 | source ~/.srht_token 78 | set -x 79 | for artifact in sprig.apk sprig-windows.zip sprig-linux.tar.xz ; do 80 | artifact_versioned=$(echo "$artifact" | sed -E "s|sprig|sprig-$tag|") 81 | mv -v "$artifact" "$artifact_versioned" 82 | artifact="$artifact_versioned" 83 | set +x 84 | echo curl -H "Authorization: token " -F "file=@$artifact" "https://git.sr.ht/api/repos/sprig/artifacts/$tag" 85 | curl -H "Authorization: token $SRHT_TOKEN" -F "file=@$artifact" "https://git.sr.ht/api/repos/sprig/artifacts/$tag" 86 | set -x 87 | done 88 | -------------------------------------------------------------------------------- /.github/workflows/issue-replication.yml: -------------------------------------------------------------------------------- 1 | name: Issue Autoresponse 2 | 3 | on: 4 | issues: 5 | types: [opened] 6 | 7 | jobs: 8 | auto-response: 9 | runs-on: ubuntu-latest 10 | 11 | steps: 12 | - uses: derekprior/add-autoresponse@master 13 | env: 14 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 15 | with: 16 | respondableId: ${{ github.event.issue.node_id }} 17 | response: "Hello! Thank you for your interest in Arbor!\nWe've chosen to mirror this repo to GitHub so that it's easier to find, but our issue tracking is done using [sourcehut](https://sourcehut.org).\nWe've automatically created a ticket in our sourcehut issue tracker with the contents of your issue. We'll follow up with you there! You can find your ticket [here!](https://todo.sr.ht/~whereswaldon/arbor-dev)\nThanks!" 18 | author: ${{ github.event.issue.user.login }} 19 | 20 | mirror: 21 | runs-on: ubuntu-latest 22 | 23 | steps: 24 | - uses: athorp96/sourcehut_issue_mirror@master 25 | with: 26 | title: ${{ github.event.issue.title }} 27 | body: ${{ github.event.issue.body }} 28 | submitter: ${{ github.event.issue.user.login }} 29 | tracker-owner: "~whereswaldon" 30 | tracker-name: "arbor-dev" 31 | oauth-token: ${{ secrets.SRHT_OAUTH_TOKEN }} 32 | label: ${{ github.event.repository.name }} 33 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | sprig.apk 2 | sprig.test 3 | sprig 4 | *.apk 5 | pakbuild/ 6 | .flatpak-builder/ 7 | vendor/ 8 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | 203 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: android_install logs windows linux macos android clean fp fp-install fp-repo fp-run 2 | 3 | SOURCE = $(shell find . -name '*\.go') go.mod go.sum 4 | APPID := chat.arbor.sprig.dev 5 | ANDROID_CONFIG = $(HOME)/.android 6 | KEYSTORE = $(ANDROID_CONFIG)/debug.keystore 7 | 8 | EMBEDDED_VERSION := $(shell git describe --tags --dirty --always || echo "git") 9 | 10 | GOFLAGS := -ldflags=-X=main.Version="$(EMBEDDED_VERSION)" 11 | 12 | ANDROID_APK = sprig.apk 13 | ANDROID_SDK_ROOT := $(ANDROID_HOME) 14 | 15 | MACOS_BIN = sprig-mac 16 | MACOS_APP = sprig.app 17 | MACOS_ARCHIVE = sprig-macos.tar.gz 18 | 19 | IOS_APP = sprig.ipa 20 | IOS_VERSION := 0 21 | 22 | tag: 23 | echo "flags" $(GOFLAGS) 24 | 25 | android: $(ANDROID_APK) 26 | 27 | $(ANDROID_APK): $(SOURCE) $(KEYSTORE) 28 | env ANDROID_SDK_ROOT=$(ANDROID_SDK_ROOT) go run gioui.org/cmd/gogio $(GOFLAGS) -x -target android -appid $(APPID) . 29 | 30 | $(KEYSTORE): 31 | mkdir -p $(ANDROID_CONFIG) 32 | keytool -genkey -v -keystore $(ANDROID_CONFIG)/debug.keystore -alias androiddebugkey -storepass android -keypass android -keyalg RSA -validity 14000 33 | 34 | windows: 35 | mage windows 36 | 37 | linux: 38 | mage linux 39 | 40 | macos: $(MACOS_ARCHIVE) 41 | 42 | $(MACOS_ARCHIVE): $(MACOS_APP) 43 | tar czf $(MACOS_ARCHIVE) $(MACOS_APP) 44 | 45 | $(MACOS_APP): $(MACOS_BIN) $(MACOS_APP).template 46 | rm -rf $(MACOS_APP) 47 | cp -rv $(MACOS_APP).template $(MACOS_APP) 48 | mkdir -p $(MACOS_APP)/Contents/MacOS 49 | cp $(MACOS_BIN) $(MACOS_APP)/Contents/MacOS/$(MACOS_BIN) 50 | mkdir -p $(MACOS_APP)/Contents/Resources 51 | go install github.com/jackmordaunt/icns/cmd/icnsify && go mod tidy 52 | cat appicon.png | icnsify > $(MACOS_APP)/Contents/Resources/sprig.icns 53 | codesign -s - $(MACOS_APP) 54 | 55 | $(MACOS_BIN): $(SOURCE) 56 | env GOOS=darwin GOFLAGS=$(GOFLAGS) CGO_CFLAGS=-mmacosx-version-min=10.14 \ 57 | CGO_LDFLAGS=-mmacosx-version-min=10.14 \ 58 | go build -o $(MACOS_BIN) -ldflags -v . 59 | 60 | ios: $(IOS_APP) 61 | 62 | $(IOS_APP): $(SOURCE) 63 | go run gioui.org/cmd/gogio $(GOFLAGS) -target ios -appid chat.arbor.sprig -version $(IOS_VERSION) . 64 | 65 | android_install: $(ANDROID_APK) 66 | adb install $(ANDROID_APK) 67 | 68 | logs: 69 | adb logcat -s -T1 $(APPID):\* 70 | 71 | fp: 72 | mage flatpak 73 | 74 | fp-shell: 75 | mage flatpakShell 76 | 77 | fp-install: 78 | mage flatpakInstall 79 | 80 | fp-run: 81 | mage flatpakRun 82 | 83 | fp-repo: 84 | mage flatpakRepo 85 | 86 | clean: 87 | mage clean 88 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## sprig 2 | 3 | Sprig is the [Arbor](https://arbor.chat) reference chat client. 4 | 5 | ![sprig screenshot](https://git.sr.ht/~whereswaldon/sprig/blob/main/img/screenshot.png) 6 | 7 | ### Try it 8 | 9 | To give it a shot on desktop, install [go 1.18+](https://golang.org/dl). 10 | 11 | Then make sure you have the 12 | [gio dependencies](https://gioui.org/doc/install#linux) for your current OS. 13 | 14 | Run: 15 | 16 | ``` 17 | # install a build system tool 18 | go install github.com/magefile/mage@latest 19 | # clone the source code 20 | git clone https://git.sr.ht/~whereswaldon/sprig 21 | # enter the source code directory 22 | cd sprig 23 | ``` 24 | 25 | Then issue a build for the platform you're targeting by executing one of these: 26 | 27 | - `windows`: `make windows` 28 | - `macos`: `make macos` (only works from a macOS computer) 29 | - `linux`: `make linux` 30 | - `android`: `make android` (requires android development environment) 31 | - `ios`: `make ios` (only works from a macOS computer) 32 | 33 | After running `make`, there should be an archive file containing a build for the 34 | target platform in your current working directory. 35 | 36 | For android in particular, you can automatically install it on a plugged-in 37 | device (in developer mode) with: 38 | 39 | ``` 40 | make android_install 41 | ``` 42 | 43 | You'll need a functional android development toolchain for that to work. 44 | -------------------------------------------------------------------------------- /anim/anim.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package anim provides simple animation primitives 3 | */ 4 | package anim 5 | 6 | import ( 7 | "time" 8 | 9 | "gioui.org/layout" 10 | "gioui.org/op" 11 | ) 12 | 13 | // Normal holds state for an animation between two states that 14 | // is not invertible. 15 | type Normal struct { 16 | time.Duration 17 | StartTime time.Time 18 | } 19 | 20 | // Progress returns the current progress through the animation 21 | // as a value in the range [0,1] 22 | func (n *Normal) Progress(gtx layout.Context) float32 { 23 | if n.Duration == time.Duration(0) { 24 | return 0 25 | } 26 | progressDur := gtx.Now.Sub(n.StartTime) 27 | if progressDur > n.Duration { 28 | return 1 29 | } 30 | op.InvalidateOp{}.Add(gtx.Ops) 31 | progress := float32(progressDur.Milliseconds()) / float32(n.Duration.Milliseconds()) 32 | return progress 33 | } 34 | 35 | func (n *Normal) Start(now time.Time) { 36 | n.StartTime = now 37 | } 38 | 39 | func (n *Normal) SetDuration(d time.Duration) { 40 | n.Duration = d 41 | } 42 | 43 | func (n *Normal) Animating(gtx layout.Context) bool { 44 | if n.Duration == 0 { 45 | return false 46 | } 47 | if gtx.Now.After(n.StartTime.Add(n.Duration)) { 48 | return false 49 | } 50 | return true 51 | } 52 | -------------------------------------------------------------------------------- /appicon-release.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arborchat/sprig/23645d553dd791d8c37ebb86dada716a32e5e6f7/appicon-release.png -------------------------------------------------------------------------------- /appicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arborchat/sprig/23645d553dd791d8c37ebb86dada716a32e5e6f7/appicon.png -------------------------------------------------------------------------------- /chat.arbor.Client.Sprig.yml: -------------------------------------------------------------------------------- 1 | app-id: chat.arbor.Client.Sprig 2 | runtime: org.freedesktop.Platform 3 | runtime-version: '19.08' 4 | sdk: org.freedesktop.Sdk 5 | command: sprig 6 | finish-args: 7 | - --socket=wayland 8 | - --socket=fallback-x11 9 | - --socket=session-bus 10 | - --filesystem=xdg-config 11 | - --share=network 12 | - --device=dri 13 | - --share=ipc 14 | cleanup-commands: 15 | - 'rm -rf $(/app/lib/sdk/golang/bin/go env GOMODCACHE) $(/app/lib/sdk/golang/bin/go env GOCACHE)' 16 | - 'rm -rf /app/lib/sdk/golang' 17 | modules: 18 | - name: golang 19 | cleanup: 20 | - /run/build/golang 21 | buildsystem: simple 22 | sources: 23 | - type: archive 24 | url: https://golang.org/dl/go1.15.linux-amd64.tar.gz 25 | sha256: 2d75848ac606061efe52a8068d0e647b35ce487a15bb52272c427df485193602 26 | build-commands: 27 | - install -d /app/lib/sdk/golang 28 | - cp -rpv * /app/lib/sdk/golang/ 29 | - name: sprig 30 | build-options: 31 | append-path: /app/lib/sdk/golang/bin 32 | build-args: 33 | - --env=GO111MODULE=on 34 | - --share=network 35 | buildsystem: simple 36 | build-commands: 37 | - go env 38 | - go build . 39 | - install -D sprig /app/bin/sprig 40 | sources: 41 | - type: git 42 | url: https://git.sr.ht/~whereswaldon/sprig 43 | branch: main 44 | 45 | -------------------------------------------------------------------------------- /connect-form-view.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/unit" 6 | "gioui.org/widget/material" 7 | materials "gioui.org/x/component" 8 | "git.sr.ht/~whereswaldon/sprig/core" 9 | sprigWidget "git.sr.ht/~whereswaldon/sprig/widget" 10 | sprigTheme "git.sr.ht/~whereswaldon/sprig/widget/theme" 11 | ) 12 | 13 | type ConnectFormView struct { 14 | manager ViewManager 15 | Form sprigWidget.TextForm 16 | 17 | core.App 18 | } 19 | 20 | var _ View = &ConnectFormView{} 21 | 22 | func NewConnectFormView(app core.App) View { 23 | c := &ConnectFormView{ 24 | App: app, 25 | } 26 | c.Form.TextField.SingleLine = true 27 | c.Form.TextField.Submit = true 28 | return c 29 | } 30 | 31 | func (c *ConnectFormView) HandleIntent(intent Intent) {} 32 | 33 | func (c *ConnectFormView) BecomeVisible() { 34 | } 35 | 36 | func (c *ConnectFormView) NavItem() *materials.NavItem { 37 | return nil 38 | } 39 | 40 | func (c *ConnectFormView) AppBarData() (bool, string, []materials.AppBarAction, []materials.OverflowAction) { 41 | return false, "", nil, nil 42 | } 43 | 44 | func (c *ConnectFormView) Update(gtx layout.Context) { 45 | if c.Form.Submitted() { 46 | c.Settings().SetAddress(c.Form.TextField.Text()) 47 | go c.Settings().Persist() 48 | c.Sprout().ConnectTo(c.Settings().Address()) 49 | c.manager.RequestViewSwitch(IdentityFormID) 50 | } 51 | } 52 | 53 | func (c *ConnectFormView) Layout(gtx layout.Context) layout.Dimensions { 54 | theme := c.Theme().Current() 55 | inset := layout.UniformInset(unit.Dp(8)) 56 | return inset.Layout(gtx, func(gtx C) D { 57 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 58 | layout.Rigid(func(gtx C) D { 59 | return inset.Layout(gtx, 60 | material.H6(theme.Theme, "Arbor Relay Address:").Layout, 61 | ) 62 | }), 63 | layout.Rigid(func(gtx C) D { 64 | return inset.Layout(gtx, sprigTheme.TextForm(theme, &c.Form, "Connect", "HOST:PORT").Layout) 65 | }), 66 | ) 67 | }) 68 | } 69 | 70 | func (c *ConnectFormView) SetManager(mgr ViewManager) { 71 | c.manager = mgr 72 | } 73 | -------------------------------------------------------------------------------- /consent-view.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/unit" 6 | "gioui.org/widget" 7 | "gioui.org/widget/material" 8 | 9 | materials "gioui.org/x/component" 10 | "git.sr.ht/~whereswaldon/sprig/core" 11 | ) 12 | 13 | type ConsentView struct { 14 | manager ViewManager 15 | AgreeButton widget.Clickable 16 | 17 | core.App 18 | } 19 | 20 | var _ View = &ConsentView{} 21 | 22 | func NewConsentView(app core.App) View { 23 | c := &ConsentView{ 24 | App: app, 25 | } 26 | 27 | return c 28 | } 29 | 30 | func (c *ConsentView) HandleIntent(intent Intent) {} 31 | 32 | func (c *ConsentView) BecomeVisible() { 33 | } 34 | 35 | func (c *ConsentView) NavItem() *materials.NavItem { 36 | return nil 37 | } 38 | 39 | func (c *ConsentView) AppBarData() (bool, string, []materials.AppBarAction, []materials.OverflowAction) { 40 | return false, "", nil, nil 41 | } 42 | 43 | func (c *ConsentView) Update(gtx layout.Context) { 44 | if c.AgreeButton.Clicked(gtx) { 45 | c.Settings().SetAcknowledgedNoticeVersion(NoticeVersion) 46 | go c.Settings().Persist() 47 | if c.Settings().Address() == "" { 48 | c.manager.RequestViewSwitch(ConnectFormID) 49 | } else { 50 | c.manager.RequestViewSwitch(SettingsID) 51 | } 52 | } 53 | } 54 | 55 | const ( 56 | UpdateText = "You are seeing this message because the notice text has changed since you last accepted it." 57 | Notice = "This is a chat client for the Arbor Chat Project. Before you send a message, you should know that your messages cannot be edited or deleted once sent, and that they will be publically visible to all other Arbor users." 58 | NoticeVersion = 1 59 | ) 60 | 61 | func (c *ConsentView) Layout(gtx layout.Context) layout.Dimensions { 62 | theme := c.Theme().Current() 63 | return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 64 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 65 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 66 | return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 67 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, 68 | material.H2(theme.Theme, "Notice").Layout, 69 | ) 70 | }) 71 | }), 72 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 73 | return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 74 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, 75 | material.Body1(theme.Theme, Notice).Layout, 76 | ) 77 | }) 78 | }), 79 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 80 | if c.Settings().AcknowledgedNoticeVersion() != 0 { 81 | return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 82 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, 83 | material.Body2(theme.Theme, UpdateText).Layout, 84 | ) 85 | }) 86 | } 87 | return layout.Dimensions{} 88 | }), 89 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 90 | return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 91 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, 92 | material.Button(theme.Theme, &(c.AgreeButton), "I Understand And Agree").Layout, 93 | ) 94 | }) 95 | }), 96 | ) 97 | }) 98 | } 99 | 100 | func (c *ConsentView) SetManager(mgr ViewManager) { 101 | c.manager = mgr 102 | } 103 | -------------------------------------------------------------------------------- /core/app.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | 8 | gioapp "gioui.org/app" 9 | "git.sr.ht/~whereswaldon/forest-go" 10 | ) 11 | 12 | // App bundles core application services into a single convenience type. 13 | type App interface { 14 | Notifications() NotificationService 15 | Arbor() ArborService 16 | Settings() SettingsService 17 | Sprout() SproutService 18 | Theme() ThemeService 19 | Status() StatusService 20 | Haptic() HapticService 21 | Banner() BannerService 22 | Window() *gioapp.Window 23 | Shutdown() 24 | } 25 | 26 | // app bundles services together. 27 | type app struct { 28 | NotificationService 29 | SettingsService 30 | ArborService 31 | SproutService 32 | ThemeService 33 | StatusService 34 | HapticService 35 | BannerService 36 | window *gioapp.Window 37 | } 38 | 39 | var _ App = &app{} 40 | 41 | // NewApp constructs an App or fails with an error. This process will fail 42 | // if any of the application services fail to initialize correctly. 43 | func NewApp(w *gioapp.Window, stateDir string) (application App, err error) { 44 | defer func() { 45 | if err != nil { 46 | err = fmt.Errorf("failed constructing app: %w", err) 47 | } 48 | }() 49 | a := &app{ 50 | window: w, 51 | } 52 | 53 | // ensure our state directory exists 54 | if err := os.MkdirAll(stateDir, 0770); err != nil { 55 | return nil, err 56 | } 57 | 58 | // Instantiate all of the services. 59 | // Settings must be initialized first, as other services rely on derived 60 | // values from it 61 | if a.SettingsService, err = newSettingsService(stateDir); err != nil { 62 | return nil, err 63 | } 64 | a.BannerService = NewBannerService(a) 65 | if a.ArborService, err = newArborService(a.SettingsService); err != nil { 66 | return nil, err 67 | } 68 | if a.NotificationService, err = newNotificationService(a.SettingsService, a.ArborService); err != nil { 69 | return nil, err 70 | } 71 | if a.SproutService, err = newSproutService(a.ArborService, a.BannerService, a.SettingsService); err != nil { 72 | return nil, err 73 | } 74 | if a.ThemeService, err = newThemeService(); err != nil { 75 | return nil, err 76 | } 77 | if a.StatusService, err = newStatusService(); err != nil { 78 | return nil, err 79 | } 80 | a.HapticService = newHapticService(w) 81 | 82 | // Connect services together 83 | if addr := a.Settings().Address(); addr != "" { 84 | a.Sprout().ConnectTo(addr) 85 | } 86 | a.Notifications().Register(a.Arbor().Store()) 87 | a.Status().Register(a.Arbor().Store()) 88 | 89 | a.Arbor().Store().SubscribeToNewMessages(func(n forest.Node) { 90 | a.Window().Invalidate() 91 | }) 92 | 93 | return a, nil 94 | } 95 | 96 | // Settings returns the app's settings service implementation. 97 | func (a *app) Settings() SettingsService { 98 | return a.SettingsService 99 | } 100 | 101 | // Arbor returns the app's arbor service implementation. 102 | func (a *app) Arbor() ArborService { 103 | return a.ArborService 104 | } 105 | 106 | // Notifications returns the app's notification service implementation. 107 | func (a *app) Notifications() NotificationService { 108 | return a.NotificationService 109 | } 110 | 111 | // Sprout returns the app's sprout service implementation. 112 | func (a *app) Sprout() SproutService { 113 | return a.SproutService 114 | } 115 | 116 | // Theme returns the app's theme service implmentation. 117 | func (a *app) Theme() ThemeService { 118 | return a.ThemeService 119 | } 120 | 121 | // Status returns the app's sprout service implementation. 122 | func (a *app) Status() StatusService { 123 | return a.StatusService 124 | } 125 | 126 | // Haptic returns the app's haptic service implementation. 127 | func (a *app) Haptic() HapticService { 128 | return a.HapticService 129 | } 130 | 131 | // Banner returns the app's banner service implementation. 132 | func (a *app) Banner() BannerService { 133 | return a.BannerService 134 | } 135 | 136 | // Shutdown performs cleanup, and blocks for the duration. 137 | func (a *app) Shutdown() { 138 | log.Printf("cleaning up") 139 | defer log.Printf("shutting down") 140 | a.Sprout().MarkSelfOffline() 141 | } 142 | 143 | // Window returns the window handle. 144 | func (a app) Window() *gioapp.Window { 145 | return a.window 146 | } 147 | -------------------------------------------------------------------------------- /core/arbor-service.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "path/filepath" 8 | "time" 9 | 10 | status "git.sr.ht/~athorp96/forest-ex/active-status" 11 | "git.sr.ht/~athorp96/forest-ex/expiration" 12 | "git.sr.ht/~whereswaldon/forest-go" 13 | "git.sr.ht/~whereswaldon/forest-go/grove" 14 | "git.sr.ht/~whereswaldon/forest-go/orchard" 15 | "git.sr.ht/~whereswaldon/forest-go/store" 16 | "git.sr.ht/~whereswaldon/sprig/ds" 17 | ) 18 | 19 | // ArborService provides access to stored arbor data. 20 | type ArborService interface { 21 | Store() store.ExtendedStore 22 | Communities() *ds.CommunityList 23 | StartHeartbeat() 24 | } 25 | 26 | type arborService struct { 27 | SettingsService 28 | grove store.ExtendedStore 29 | cl *ds.CommunityList 30 | done chan struct{} 31 | } 32 | 33 | var _ ArborService = &arborService{} 34 | 35 | // newArborService creates a new instance of the Arbor Service using 36 | // the provided Settings within the app to acquire configuration. 37 | func newArborService(settings SettingsService) (ArborService, error) { 38 | s, err := func() (forest.Store, error) { 39 | path := settings.DataPath() 40 | if err := os.MkdirAll(path, 0770); err != nil { 41 | return nil, fmt.Errorf("preparing data directory for store: %v", err) 42 | } 43 | if settings.UseOrchardStore() { 44 | o, err := orchard.Open(filepath.Join(path, "orchard.db")) 45 | if err != nil { 46 | return nil, fmt.Errorf("opening Orchard store: %v", err) 47 | } 48 | return o, nil 49 | } 50 | g, err := grove.New(path) 51 | if err != nil { 52 | return nil, fmt.Errorf("opening Grove store: %v", err) 53 | } 54 | g.SetCorruptNodeHandler(func(id string) { 55 | log.Printf("Grove: corrupt node %s", id) 56 | }) 57 | return g, nil 58 | }() 59 | if err != nil { 60 | s = store.NewMemoryStore() 61 | } 62 | log.Printf("Store: %T\n", s) 63 | a := &arborService{ 64 | SettingsService: settings, 65 | grove: store.NewArchive(s), 66 | done: make(chan struct{}), 67 | } 68 | cl, err := ds.NewCommunityList(a.grove) 69 | if err != nil { 70 | return nil, err 71 | } 72 | a.cl = cl 73 | expiration.ExpiredPurger{ 74 | Logger: log.New(log.Writer(), "purge ", log.Flags()), 75 | ExtendedStore: a.grove, 76 | PurgeInterval: time.Hour, 77 | }.Start(a.done) 78 | return a, nil 79 | } 80 | 81 | func (a *arborService) Store() store.ExtendedStore { 82 | return a.grove 83 | } 84 | 85 | func (a *arborService) Communities() *ds.CommunityList { 86 | return a.cl 87 | } 88 | 89 | func (a *arborService) StartHeartbeat() { 90 | a.Communities().WithCommunities(func(c []*forest.Community) { 91 | if a.SettingsService.ActiveArborIdentityID() != nil { 92 | builder, err := a.SettingsService.Builder() 93 | if err == nil { 94 | log.Printf("Begining active-status heartbeat") 95 | go status.StartActivityHeartBeat(a.Store(), c, builder, time.Minute*5) 96 | } else { 97 | log.Printf("Could not acquire builder: %v", err) 98 | } 99 | } 100 | }) 101 | } 102 | -------------------------------------------------------------------------------- /core/banner-service.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import "sort" 4 | 5 | // Banner is a type that provides details for a persistent on-screen 6 | // notification banner 7 | type Banner interface { 8 | BannerPriority() Priority 9 | Cancel() 10 | IsCancelled() bool 11 | } 12 | 13 | // BannerService provides methods for creating and managing on-screen 14 | // persistent banners. The methods must be safe for concurrent use. 15 | type BannerService interface { 16 | // Add establishes a new banner managed by the service. 17 | Add(Banner) 18 | // Top returns the banner that should be displayed right now 19 | Top() Banner 20 | } 21 | 22 | type bannerService struct { 23 | App 24 | newBanners chan Banner 25 | banners []Banner 26 | } 27 | 28 | var _ BannerService = &bannerService{} 29 | 30 | func NewBannerService(app App) BannerService { 31 | return &bannerService{ 32 | newBanners: make(chan Banner, 1), 33 | App: app, 34 | } 35 | } 36 | 37 | func (b *bannerService) Add(banner Banner) { 38 | b.newBanners <- banner 39 | b.App.Window().Invalidate() 40 | } 41 | 42 | func (b *bannerService) Top() Banner { 43 | select { 44 | case banner := <-b.newBanners: 45 | b.banners = append(b.banners, banner) 46 | sort.Slice(b.banners, func(i, j int) bool { 47 | return b.banners[i].BannerPriority() > b.banners[j].BannerPriority() 48 | }) 49 | default: 50 | } 51 | if len(b.banners) < 1 { 52 | return nil 53 | } 54 | first := b.banners[0] 55 | for first.IsCancelled() { 56 | b.banners = b.banners[1:] 57 | if len(b.banners) < 1 { 58 | return nil 59 | } 60 | first = b.banners[0] 61 | } 62 | return first 63 | } 64 | 65 | type Priority uint8 66 | 67 | const ( 68 | Debug Priority = iota 69 | Info 70 | Warn 71 | Error 72 | ) 73 | 74 | // LoadingBanner requests a banner with a loading spinner displayed along with 75 | // the provided text. It will not disappear until cancelled. 76 | type LoadingBanner struct { 77 | Priority 78 | Text string 79 | cancelled bool 80 | } 81 | 82 | func (l *LoadingBanner) BannerPriority() Priority { 83 | return l.Priority 84 | } 85 | 86 | func (l *LoadingBanner) Cancel() { 87 | l.cancelled = true 88 | } 89 | 90 | func (l *LoadingBanner) IsCancelled() bool { 91 | return l.cancelled 92 | } 93 | -------------------------------------------------------------------------------- /core/haptic-service.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "log" 5 | 6 | gioapp "gioui.org/app" 7 | "gioui.org/x/haptic" 8 | ) 9 | 10 | // HapticService provides access to haptic feedback devices features. 11 | type HapticService interface { 12 | UpdateAndroidViewRef(uintptr) 13 | Buzz() 14 | } 15 | 16 | type hapticService struct { 17 | *haptic.Buzzer 18 | } 19 | 20 | func newHapticService(w *gioapp.Window) HapticService { 21 | return &hapticService{ 22 | Buzzer: haptic.NewBuzzer(w), 23 | } 24 | } 25 | 26 | func (h *hapticService) UpdateAndroidViewRef(view uintptr) { 27 | h.Buzzer.SetView(view) 28 | } 29 | 30 | func (h *hapticService) Buzz() { 31 | defer func() { 32 | if err := recover(); err != nil { 33 | log.Printf("Recovered from buzz panic: %v", err) 34 | } 35 | }() 36 | h.Buzzer.Buzz() 37 | } 38 | -------------------------------------------------------------------------------- /core/notification-service.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "strings" 7 | "time" 8 | 9 | niotify "gioui.org/x/notify" 10 | "git.sr.ht/~whereswaldon/forest-go" 11 | "git.sr.ht/~whereswaldon/forest-go/store" 12 | ) 13 | 14 | // NotificationService provides methods to send notifications and to 15 | // configure notifications for collections of arbor nodes. 16 | type NotificationService interface { 17 | Register(store.ExtendedStore) 18 | Notify(title, content string) error 19 | } 20 | 21 | // notificationManager implements NotificationService and provides 22 | // methods to send notifications and choose (based on settings) 23 | // whether to notify for a given arbor message. 24 | type notificationManager struct { 25 | SettingsService 26 | ArborService 27 | niotify.Notifier 28 | TimeLaunched uint64 29 | } 30 | 31 | var _ NotificationService = ¬ificationManager{} 32 | 33 | // newNotificationService constructs a new NotificationService for the 34 | // provided App. 35 | func newNotificationService(settings SettingsService, arbor ArborService) (NotificationService, error) { 36 | m, err := niotify.NewNotifier() 37 | if err != nil { 38 | return nil, fmt.Errorf("failed initializing notification support: %w", err) 39 | } 40 | return ¬ificationManager{ 41 | SettingsService: settings, 42 | ArborService: arbor, 43 | Notifier: m, 44 | TimeLaunched: uint64(time.Now().UnixNano() / 1000000), 45 | }, nil 46 | } 47 | 48 | // Register configures the store so that new nodes will generate notifications 49 | // if notifications are appropriate (based on current user settings). 50 | func (n *notificationManager) Register(s store.ExtendedStore) { 51 | s.SubscribeToNewMessages(n.handleNode) 52 | } 53 | 54 | // shouldNotify returns whether or not a node should generate a notification 55 | // according to the user's current settings. 56 | func (n *notificationManager) shouldNotify(reply *forest.Reply) bool { 57 | if !n.SettingsService.NotificationsGloballyAllowed() { 58 | return false 59 | } 60 | if md, err := reply.TwigMetadata(); err != nil || md.Contains("invisible", 1) { 61 | // Invisible message 62 | return false 63 | } 64 | localUserID := n.SettingsService.ActiveArborIdentityID() 65 | if localUserID == nil { 66 | return false 67 | } 68 | localUserNode, has, err := n.ArborService.Store().GetIdentity(localUserID) 69 | if err != nil || !has { 70 | return false 71 | } 72 | 73 | twigData, err := reply.TwigMetadata() 74 | if err != nil { 75 | log.Printf("Error checking whether to notify while parsing twig metadata for node %s", reply.ID()) 76 | } else { 77 | if twigData.Contains("invisible", 1) { 78 | return false 79 | } 80 | } 81 | 82 | localUser := localUserNode.(*forest.Identity) 83 | messageContent := strings.ToLower(string(reply.Content.Blob)) 84 | username := strings.ToLower(string(localUser.Name.Blob)) 85 | 86 | if strings.Contains(messageContent, username) { 87 | // local user directly mentioned 88 | return true 89 | } 90 | if uint64(reply.Created) < n.TimeLaunched { 91 | // do not send old notifications 92 | return false 93 | } 94 | if reply.Author.Equals(localUserID) { 95 | // Do not send notifications for replies created by the local 96 | // user's identity. 97 | return false 98 | } 99 | if reply.TreeDepth() == 1 { 100 | // Notify of new conversation 101 | return true 102 | } 103 | parent, known, err := n.ArborService.Store().Get(reply.ParentID()) 104 | if err != nil || !known { 105 | // Don't notify if we don't know about this conversation. 106 | return false 107 | } 108 | if parent.(*forest.Reply).Author.Equals(localUserID) { 109 | // Direct response to local user. 110 | return true 111 | } 112 | return false 113 | } 114 | 115 | // Notify sends a notification with the given title and content if 116 | // notifications are currently allowed. 117 | func (n *notificationManager) Notify(title, content string) error { 118 | if !n.SettingsService.NotificationsGloballyAllowed() { 119 | return nil 120 | } 121 | _, err := n.CreateNotification(title, content) 122 | if err != nil { 123 | return fmt.Errorf("failed to create notification: %w", err) 124 | } 125 | return nil 126 | } 127 | 128 | // handleNode spawns a worker goroutine to decide whether or not 129 | // to notify for a given node. This makes it appropriate as a subscriber 130 | // function on a store.ExtendedStore, as it will not block. 131 | func (n *notificationManager) handleNode(node forest.Node) { 132 | if asReply, ok := node.(*forest.Reply); ok { 133 | go func(reply *forest.Reply) { 134 | if !n.shouldNotify(reply) { 135 | return 136 | } 137 | var title, authorName string 138 | author, _, err := n.ArborService.Store().GetIdentity(&reply.Author) 139 | if err != nil { 140 | authorName = "???" 141 | } else { 142 | authorName = string(author.(*forest.Identity).Name.Blob) 143 | } 144 | switch { 145 | case reply.Depth == 1: 146 | title = fmt.Sprintf("New conversation by %s", authorName) 147 | default: 148 | title = fmt.Sprintf("New reply from %s", authorName) 149 | } 150 | err = n.Notify(title, string(reply.Content.Blob)) 151 | if err != nil { 152 | log.Printf("failed sending notification: %v", err) 153 | } 154 | }(asReply) 155 | } 156 | } 157 | -------------------------------------------------------------------------------- /core/settings-service.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io/ioutil" 7 | "log" 8 | "os" 9 | "path/filepath" 10 | "sync" 11 | 12 | "git.sr.ht/~whereswaldon/forest-go" 13 | "git.sr.ht/~whereswaldon/forest-go/fields" 14 | "golang.org/x/crypto/openpgp" 15 | "golang.org/x/crypto/openpgp/packet" 16 | ) 17 | 18 | // SettingsService allows querying, updating, and saving settings. 19 | type SettingsService interface { 20 | NotificationsGloballyAllowed() bool 21 | SetNotificationsGloballyAllowed(bool) 22 | AcknowledgedNoticeVersion() int 23 | SetAcknowledgedNoticeVersion(version int) 24 | AddSubscription(id string) 25 | RemoveSubscription(id string) 26 | Subscriptions() []string 27 | Address() string 28 | SetAddress(string) 29 | BottomAppBar() bool 30 | SetBottomAppBar(bool) 31 | DockNavDrawer() bool 32 | SetDockNavDrawer(bool) 33 | DarkMode() bool 34 | SetDarkMode(bool) 35 | ActiveArborIdentityID() *fields.QualifiedHash 36 | Identity() (*forest.Identity, error) 37 | DataPath() string 38 | Persist() error 39 | CreateIdentity(name string) error 40 | Builder() (*forest.Builder, error) 41 | UseOrchardStore() bool 42 | SetUseOrchardStore(bool) 43 | } 44 | 45 | type Settings struct { 46 | // relay address to connect to 47 | Address string 48 | 49 | // user's local identity ID 50 | ActiveIdentity *fields.QualifiedHash 51 | 52 | // the version of the disclaimer that the user has accepted 53 | AcknowledgedNoticeVersion int 54 | 55 | // whether notifications are accepted. The nil state indicates that 56 | // the user has not changed this value, and should be treated as true. 57 | // TODO(whereswaldon): find a backwards-compatible way to handle this 58 | // elegantly. 59 | NotificationsEnabled *bool 60 | 61 | // whether the user wants the app bar anchored at the bottom of the UI 62 | BottomAppBar bool 63 | 64 | DarkMode bool 65 | 66 | // whether the user wants the navigation drawer to dock to the side of 67 | // the UI instead of appearing on top 68 | DockNavDrawer bool 69 | 70 | // whether the user wants to use the beta Orchard store for node storage. 71 | // Will become default in future release. 72 | OrchardStore bool 73 | 74 | Subscriptions []string 75 | } 76 | 77 | type settingsService struct { 78 | subscriptionLock sync.Mutex 79 | Settings 80 | dataDir string 81 | // state used for authoring messages 82 | activePrivKey *openpgp.Entity 83 | activeIdCache *forest.Identity 84 | } 85 | 86 | var _ SettingsService = &settingsService{} 87 | 88 | func newSettingsService(stateDir string) (SettingsService, error) { 89 | s := &settingsService{ 90 | dataDir: stateDir, 91 | } 92 | if err := s.Load(); err != nil { 93 | log.Printf("no loadable settings file found; defaults will be used: %v", err) 94 | } 95 | s.DiscoverIdentities() 96 | return s, nil 97 | } 98 | 99 | func (s *settingsService) Load() error { 100 | jsonSettings, err := ioutil.ReadFile(s.SettingsFile()) 101 | if err != nil { 102 | return fmt.Errorf("failed to load settings: %w", err) 103 | } 104 | if err = json.Unmarshal(jsonSettings, &s.Settings); err != nil { 105 | return fmt.Errorf("couldn't parse json settings: %w", err) 106 | } 107 | return nil 108 | } 109 | 110 | func (s *settingsService) AddSubscription(id string) { 111 | s.subscriptionLock.Lock() 112 | defer s.subscriptionLock.Unlock() 113 | found := false 114 | for _, comm := range s.Settings.Subscriptions { 115 | if comm == id { 116 | found = true 117 | break 118 | } 119 | } 120 | if !found { 121 | s.Settings.Subscriptions = append(s.Settings.Subscriptions, id) 122 | } 123 | } 124 | 125 | func (s *settingsService) RemoveSubscription(id string) { 126 | s.subscriptionLock.Lock() 127 | defer s.subscriptionLock.Unlock() 128 | length := len(s.Settings.Subscriptions) 129 | for i, comm := range s.Settings.Subscriptions { 130 | if comm == id { 131 | s.Settings.Subscriptions = append(s.Settings.Subscriptions[:i], s.Settings.Subscriptions[i+1:length]...) 132 | return 133 | } 134 | } 135 | } 136 | 137 | func (s *settingsService) Subscriptions() []string { 138 | s.subscriptionLock.Lock() 139 | defer s.subscriptionLock.Unlock() 140 | var out []string 141 | out = append(out, s.Settings.Subscriptions...) 142 | return out 143 | } 144 | 145 | func (s *settingsService) DockNavDrawer() bool { 146 | return s.Settings.DockNavDrawer 147 | } 148 | 149 | func (s *settingsService) SetDockNavDrawer(shouldDock bool) { 150 | s.Settings.DockNavDrawer = shouldDock 151 | } 152 | 153 | func (s *settingsService) AcknowledgedNoticeVersion() int { 154 | return s.Settings.AcknowledgedNoticeVersion 155 | } 156 | 157 | func (s *settingsService) SetAcknowledgedNoticeVersion(version int) { 158 | s.Settings.AcknowledgedNoticeVersion = version 159 | } 160 | 161 | func (s *settingsService) NotificationsGloballyAllowed() bool { 162 | return s.Settings.NotificationsEnabled == nil || *s.Settings.NotificationsEnabled 163 | } 164 | 165 | func (s *settingsService) SetNotificationsGloballyAllowed(allowed bool) { 166 | s.Settings.NotificationsEnabled = &allowed 167 | } 168 | 169 | func (s *settingsService) ActiveArborIdentityID() *fields.QualifiedHash { 170 | return s.Settings.ActiveIdentity 171 | } 172 | 173 | func (s *settingsService) Address() string { 174 | return s.Settings.Address 175 | } 176 | 177 | func (s *settingsService) SetAddress(addr string) { 178 | s.Settings.Address = addr 179 | } 180 | 181 | func (s *settingsService) DataPath() string { 182 | return filepath.Join(s.dataDir, "data") 183 | } 184 | 185 | func (s *settingsService) BottomAppBar() bool { 186 | return s.Settings.BottomAppBar 187 | } 188 | 189 | func (s *settingsService) SetBottomAppBar(bottom bool) { 190 | s.Settings.BottomAppBar = bottom 191 | } 192 | 193 | func (s *settingsService) DarkMode() bool { 194 | return s.Settings.DarkMode 195 | } 196 | 197 | func (s *settingsService) SetDarkMode(enabled bool) { 198 | s.Settings.DarkMode = enabled 199 | } 200 | 201 | func (s *settingsService) UseOrchardStore() bool { 202 | return s.Settings.OrchardStore 203 | } 204 | 205 | func (s *settingsService) SetUseOrchardStore(enabled bool) { 206 | s.Settings.OrchardStore = enabled 207 | } 208 | 209 | func (s *settingsService) SettingsFile() string { 210 | return filepath.Join(s.dataDir, "settings.json") 211 | } 212 | 213 | func (s *settingsService) KeysDir() string { 214 | return filepath.Join(s.dataDir, "keys") 215 | } 216 | 217 | func (s *settingsService) IdentitiesDir() string { 218 | return filepath.Join(s.dataDir, "identities") 219 | } 220 | 221 | func (s *settingsService) DiscoverIdentities() error { 222 | idsDir, err := os.Open(s.IdentitiesDir()) 223 | if err != nil { 224 | return fmt.Errorf("failed opening identities directory: %w", err) 225 | } 226 | names, err := idsDir.Readdirnames(0) 227 | if err != nil { 228 | return fmt.Errorf("failed listing identities directory: %w", err) 229 | } 230 | name := names[0] 231 | id := &fields.QualifiedHash{} 232 | err = id.UnmarshalText([]byte(name)) 233 | if err != nil { 234 | return fmt.Errorf("failed unmarshalling name of first identity %s: %w", name, err) 235 | } 236 | s.ActiveIdentity = id 237 | return nil 238 | } 239 | 240 | func (s *settingsService) Identity() (*forest.Identity, error) { 241 | if s.ActiveIdentity == nil { 242 | return nil, fmt.Errorf("no identity configured") 243 | } 244 | if s.activeIdCache != nil { 245 | return s.activeIdCache, nil 246 | } 247 | idData, err := ioutil.ReadFile(filepath.Join(s.IdentitiesDir(), s.ActiveIdentity.String())) 248 | if err != nil { 249 | return nil, fmt.Errorf("failed reading identity data: %w", err) 250 | } 251 | identity, err := forest.UnmarshalIdentity(idData) 252 | if err != nil { 253 | return nil, fmt.Errorf("failed decoding identity data: %w", err) 254 | } 255 | s.activeIdCache = identity 256 | return identity, nil 257 | } 258 | 259 | func (s *settingsService) Signer() (forest.Signer, error) { 260 | if s.ActiveIdentity == nil { 261 | return nil, fmt.Errorf("no identity configured, therefore no private key") 262 | } 263 | var privkey *openpgp.Entity 264 | if s.activePrivKey != nil { 265 | privkey = s.activePrivKey 266 | } else { 267 | keyfilePath := filepath.Join(s.KeysDir(), s.ActiveIdentity.String()) 268 | keyfile, err := os.Open(keyfilePath) 269 | if err != nil { 270 | return nil, fmt.Errorf("unable to read key file: %w", err) 271 | } 272 | defer keyfile.Close() 273 | privkey, err = openpgp.ReadEntity(packet.NewReader(keyfile)) 274 | if err != nil { 275 | return nil, fmt.Errorf("unable to decode key data: %w", err) 276 | } 277 | s.activePrivKey = privkey 278 | } 279 | signer, err := forest.NewNativeSigner(privkey) 280 | if err != nil { 281 | return nil, fmt.Errorf("couldn't wrap privkey in forest signer: %w", err) 282 | } 283 | return signer, nil 284 | } 285 | 286 | func (s *settingsService) Builder() (*forest.Builder, error) { 287 | id, err := s.Identity() 288 | if err != nil { 289 | return nil, err 290 | } 291 | signer, err := s.Signer() 292 | if err != nil { 293 | return nil, err 294 | } 295 | builder := forest.As(id, signer) 296 | return builder, nil 297 | } 298 | 299 | func (s *settingsService) CreateIdentity(name string) (err error) { 300 | keysDir := s.KeysDir() 301 | if err := os.MkdirAll(keysDir, 0770); err != nil { 302 | return fmt.Errorf("failed creating key storage directory: %w", err) 303 | } 304 | keypair, err := openpgp.NewEntity(name, "sprig-generated arbor identity", "", &packet.Config{}) 305 | if err != nil { 306 | return fmt.Errorf("failed generating new keypair: %w", err) 307 | } 308 | signer, err := forest.NewNativeSigner(keypair) 309 | if err != nil { 310 | return fmt.Errorf("failed wrapping keypair into Signer: %w", err) 311 | } 312 | identity, err := forest.NewIdentity(signer, name, []byte{}) 313 | if err != nil { 314 | return fmt.Errorf("failed generating arbor identity from signer: %w", err) 315 | } 316 | id := identity.ID() 317 | 318 | keyFilePath := filepath.Join(keysDir, id.String()) 319 | keyFile, err := os.OpenFile(keyFilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0660) 320 | if err != nil { 321 | return fmt.Errorf("failed creating key file: %w", err) 322 | } 323 | defer func() { 324 | if err != nil { 325 | if err = keyFile.Close(); err != nil { 326 | err = fmt.Errorf("failed closing key file: %w", err) 327 | } 328 | } 329 | }() 330 | if err := keypair.SerializePrivateWithoutSigning(keyFile, nil); err != nil { 331 | return fmt.Errorf("failed saving private key: %w", err) 332 | } 333 | 334 | idsDir := s.IdentitiesDir() 335 | if err := os.MkdirAll(idsDir, 0770); err != nil { 336 | return fmt.Errorf("failed creating identity storage directory: %w", err) 337 | } 338 | idFilePath := filepath.Join(idsDir, id.String()) 339 | 340 | idFile, err := os.OpenFile(idFilePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0660) 341 | if err != nil { 342 | return fmt.Errorf("failed creating identity file: %w", err) 343 | } 344 | defer func() { 345 | if err != nil { 346 | if err = idFile.Close(); err != nil { 347 | err = fmt.Errorf("failed closing identity file: %w", err) 348 | } 349 | } 350 | }() 351 | binIdent, err := identity.MarshalBinary() 352 | if err != nil { 353 | return fmt.Errorf("failed serializing new identity: %w", err) 354 | } 355 | if _, err := idFile.Write(binIdent); err != nil { 356 | return fmt.Errorf("failed writing identity: %w", err) 357 | } 358 | 359 | s.ActiveIdentity = id 360 | s.activePrivKey = keypair 361 | return s.Persist() 362 | } 363 | 364 | func (s *settingsService) Persist() error { 365 | data, err := json.MarshalIndent(&s, "", " ") 366 | if err != nil { 367 | return fmt.Errorf("couldn't marshal settings as json: %w", err) 368 | } 369 | err = ioutil.WriteFile(s.SettingsFile(), data, 0770) 370 | if err != nil { 371 | return fmt.Errorf("couldn't save settings file: %w", err) 372 | } 373 | return nil 374 | } 375 | -------------------------------------------------------------------------------- /core/sprout-service.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | "crypto/tls" 5 | "fmt" 6 | "log" 7 | "sync" 8 | "time" 9 | 10 | status "git.sr.ht/~athorp96/forest-ex/active-status" 11 | "git.sr.ht/~whereswaldon/forest-go" 12 | "git.sr.ht/~whereswaldon/forest-go/fields" 13 | "git.sr.ht/~whereswaldon/forest-go/store" 14 | "git.sr.ht/~whereswaldon/sprout-go" 15 | ) 16 | 17 | type SproutService interface { 18 | ConnectTo(address string) error 19 | Connections() []string 20 | WorkerFor(address string) *sprout.Worker 21 | MarkSelfOffline() 22 | } 23 | 24 | type sproutService struct { 25 | ArborService 26 | BannerService 27 | SettingsService 28 | workerLock sync.Mutex 29 | workerDone chan struct{} 30 | workers map[string]*sprout.Worker 31 | } 32 | 33 | var _ SproutService = &sproutService{} 34 | 35 | func newSproutService(arbor ArborService, banner BannerService, settings SettingsService) (SproutService, error) { 36 | s := &sproutService{ 37 | ArborService: arbor, 38 | BannerService: banner, 39 | SettingsService: settings, 40 | workers: make(map[string]*sprout.Worker), 41 | workerDone: make(chan struct{}), 42 | } 43 | return s, nil 44 | } 45 | 46 | // ConnectTo (re)connects to the specified address. 47 | func (s *sproutService) ConnectTo(address string) error { 48 | s.workerLock.Lock() 49 | defer s.workerLock.Unlock() 50 | if s.workerDone != nil { 51 | close(s.workerDone) 52 | } 53 | s.workerDone = make(chan struct{}) 54 | go s.launchWorker(address) 55 | return nil 56 | } 57 | 58 | func (s *sproutService) Connections() []string { 59 | s.workerLock.Lock() 60 | defer s.workerLock.Unlock() 61 | out := make([]string, 0, len(s.workers)) 62 | for addr := range s.workers { 63 | out = append(out, addr) 64 | } 65 | return out 66 | } 67 | 68 | func (s *sproutService) WorkerFor(address string) *sprout.Worker { 69 | s.workerLock.Lock() 70 | defer s.workerLock.Unlock() 71 | out, defined := s.workers[address] 72 | if !defined { 73 | return nil 74 | } 75 | return out 76 | } 77 | 78 | func (s *sproutService) launchWorker(addr string) { 79 | firstAttempt := true 80 | logger := log.New(log.Writer(), "worker "+addr, log.LstdFlags|log.Lshortfile) 81 | for { 82 | worker, done := func() (*sprout.Worker, chan struct{}) { 83 | connectionBanner := &LoadingBanner{ 84 | Priority: Info, 85 | Text: "Connecting to " + addr + "...", 86 | } 87 | defer connectionBanner.Cancel() 88 | s.BannerService.Add(connectionBanner) 89 | if !firstAttempt { 90 | logger.Printf("Restarting worker for address %s", addr) 91 | time.Sleep(time.Second) 92 | } 93 | firstAttempt = false 94 | 95 | s.workerLock.Lock() 96 | done := s.workerDone 97 | s.workerLock.Unlock() 98 | 99 | worker, err := NewWorker(addr, done, s.ArborService.Store()) 100 | if err != nil { 101 | log.Printf("Failed starting worker: %v", err) 102 | return nil, nil 103 | } 104 | worker.Logger = log.New(logger.Writer(), fmt.Sprintf("worker-%v ", addr), log.Flags()) 105 | 106 | s.workerLock.Lock() 107 | s.workers[addr] = worker 108 | s.workerLock.Unlock() 109 | return worker, done 110 | }() 111 | if worker == nil { 112 | continue 113 | } 114 | 115 | go func() { 116 | synchronizingBanner := &LoadingBanner{ 117 | Priority: Info, 118 | Text: "Syncing with " + addr + "...", 119 | } 120 | s.BannerService.Add(synchronizingBanner) 121 | defer synchronizingBanner.Cancel() 122 | BootstrapSubscribed(worker, s.SettingsService.Subscriptions()) 123 | }() 124 | 125 | worker.Run() 126 | select { 127 | case <-done: 128 | return 129 | default: 130 | } 131 | } 132 | } 133 | 134 | // MarkSelfOffline announces that the local user is offline in all known 135 | // communities. 136 | func (s *sproutService) MarkSelfOffline() { 137 | for _, conn := range s.Connections() { 138 | if worker := s.WorkerFor(conn); worker != nil { 139 | var ( 140 | nodes []forest.Node 141 | ) 142 | s.ArborService.Communities().WithCommunities(func(coms []*forest.Community) { 143 | if s.SettingsService.ActiveArborIdentityID() != nil { 144 | builder, err := s.SettingsService.Builder() 145 | if err == nil { 146 | log.Printf("killing active-status heartbeat") 147 | for _, c := range coms { 148 | n, err := status.NewActivityNode(c, builder, status.Inactive, time.Minute*5) 149 | if err != nil { 150 | log.Printf("creating inactive node: %v", err) 151 | continue 152 | } 153 | log.Printf("sending offline node to community %s", c.ID()) 154 | nodes = append(nodes, n) 155 | } 156 | } else { 157 | log.Printf("aquiring builder: %v", err) 158 | } 159 | } 160 | }) 161 | if err := worker.SendAnnounce(nodes, time.NewTicker(time.Second*5).C); err != nil { 162 | log.Printf("sending shutdown messages: %v", err) 163 | } 164 | } 165 | } 166 | } 167 | 168 | func makeTicker(duration time.Duration) <-chan time.Time { 169 | return time.NewTicker(duration).C 170 | } 171 | 172 | func BootstrapSubscribed(worker *sprout.Worker, subscribed []string) error { 173 | leaves := 1024 174 | communities, err := worker.SendList(fields.NodeTypeCommunity, leaves, makeTicker(worker.DefaultTimeout)) 175 | if err != nil { 176 | worker.Printf("Failed listing peer communities: %v", err) 177 | return err 178 | } 179 | subbed := map[string]bool{} 180 | for _, s := range subscribed { 181 | subbed[s] = true 182 | } 183 | for _, node := range communities.Nodes { 184 | community, isCommunity := node.(*forest.Community) 185 | if !isCommunity { 186 | worker.Printf("Got response in community list that isn't a community: %s", node.ID().String()) 187 | continue 188 | } 189 | if !subbed[community.ID().String()] { 190 | continue 191 | } 192 | if err := worker.IngestNode(community); err != nil { 193 | worker.Printf("Couldn't ingest community %s: %v", community.ID().String(), err) 194 | continue 195 | } 196 | if err := worker.SendSubscribe(community, makeTicker(worker.DefaultTimeout)); err != nil { 197 | worker.Printf("Couldn't subscribe to community %s", community.ID().String()) 198 | continue 199 | } 200 | worker.Subscribe(community.ID()) 201 | worker.Printf("Subscribed to %s", community.ID().String()) 202 | if err := worker.SynchronizeFullTree(community, leaves, worker.DefaultTimeout); err != nil { 203 | worker.Printf("Couldn't fetch message tree rooted at community %s: %v", community.ID().String(), err) 204 | continue 205 | } 206 | } 207 | return nil 208 | } 209 | 210 | // NewWorker creates a sprout worker connected to the provided address using 211 | // TLS over TCP as a transport. 212 | func NewWorker(addr string, done <-chan struct{}, s store.ExtendedStore) (*sprout.Worker, error) { 213 | conn, err := tls.Dial("tcp", addr, nil) 214 | if err != nil { 215 | return nil, fmt.Errorf("failed to connect to %s: %v", addr, err) 216 | } 217 | 218 | worker, err := sprout.NewWorker(done, conn, s) 219 | if err != nil { 220 | return nil, fmt.Errorf("failed launching worker to connect to address %s: %v", addr, err) 221 | } 222 | 223 | return worker, nil 224 | } 225 | -------------------------------------------------------------------------------- /core/status-service.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | status "git.sr.ht/~athorp96/forest-ex/active-status" 5 | "git.sr.ht/~whereswaldon/forest-go/fields" 6 | "git.sr.ht/~whereswaldon/forest-go/store" 7 | ) 8 | 9 | // StatusService provides information on the online status of users. 10 | type StatusService interface { 11 | Register(store.ExtendedStore) 12 | IsActive(*fields.QualifiedHash) bool 13 | } 14 | 15 | type statusService struct { 16 | *status.StatusManager 17 | } 18 | 19 | var _ StatusService = &statusService{} 20 | 21 | func newStatusService() (StatusService, error) { 22 | return &statusService{ 23 | StatusManager: status.NewStatusManager(), 24 | }, nil 25 | } 26 | 27 | // Register subscribes the StatusService to new nodes within 28 | // the provided store. 29 | func (s *statusService) Register(stor store.ExtendedStore) { 30 | stor.SubscribeToNewMessages(s.StatusManager.HandleNode) 31 | } 32 | 33 | // IsActive returns whether or not a given user is listed as currently 34 | // active. If the user has never been registered by the StatusManager, 35 | // they are considered inactive. 36 | func (s *statusService) IsActive(id *fields.QualifiedHash) bool { 37 | return s.StatusManager.IsActive(*id) 38 | } 39 | -------------------------------------------------------------------------------- /core/theme-service.go: -------------------------------------------------------------------------------- 1 | package core 2 | 3 | import ( 4 | sprigTheme "git.sr.ht/~whereswaldon/sprig/widget/theme" 5 | ) 6 | 7 | // ThemeService provides methods to fetch and manipulate the current 8 | // application theme. 9 | type ThemeService interface { 10 | Current() *sprigTheme.Theme 11 | SetDarkMode(bool) 12 | } 13 | 14 | // themeService implements ThemeService. 15 | type themeService struct { 16 | *sprigTheme.Theme 17 | darkTheme *sprigTheme.Theme 18 | useDark bool 19 | } 20 | 21 | var _ ThemeService = &themeService{} 22 | 23 | func newThemeService() (ThemeService, error) { 24 | dark := sprigTheme.New() 25 | dark.ToDark() 26 | return &themeService{ 27 | Theme: sprigTheme.New(), 28 | darkTheme: dark, 29 | }, nil 30 | } 31 | 32 | // Current returns the current theme. 33 | func (t *themeService) Current() *sprigTheme.Theme { 34 | if !t.useDark { 35 | return t.Theme 36 | } 37 | return t.darkTheme 38 | } 39 | 40 | func (t *themeService) SetDarkMode(enabled bool) { 41 | t.useDark = enabled 42 | } 43 | -------------------------------------------------------------------------------- /desktop-assets/sprig.desktop: -------------------------------------------------------------------------------- 1 | [Desktop Entry] 2 | Version=1.0 3 | Name=Sprig 4 | Exec=sprig 5 | Terminal=false 6 | Type=Application 7 | Icon=sprig.png 8 | Categories=Chat;Network;InstantMessaging; 9 | -------------------------------------------------------------------------------- /ds/community-list.go: -------------------------------------------------------------------------------- 1 | /* 2 | Package ds implements useful data structures for sprig. 3 | */ 4 | package ds 5 | 6 | import ( 7 | "fmt" 8 | "sort" 9 | "sync" 10 | "time" 11 | 12 | "git.sr.ht/~gioverse/chat/list" 13 | forest "git.sr.ht/~whereswaldon/forest-go" 14 | "git.sr.ht/~whereswaldon/forest-go/fields" 15 | "git.sr.ht/~whereswaldon/forest-go/store" 16 | "git.sr.ht/~whereswaldon/forest-go/twig" 17 | ) 18 | 19 | // CommunityList holds a sortable list of communities that can update itself 20 | // automatically by subscribing to a store.ExtendedStore 21 | type CommunityList struct { 22 | communities []*forest.Community 23 | nodelist *NodeList 24 | } 25 | 26 | // NewCommunityList creates a CommunityList and subscribes it to the provided ExtendedStore. 27 | // It will prepopulate the list with the contents of the store as well. 28 | func NewCommunityList(s store.ExtendedStore) (*CommunityList, error) { 29 | cl := new(CommunityList) 30 | var err error 31 | var nodes []forest.Node 32 | cl.nodelist = NewNodeList(func(node forest.Node) forest.Node { 33 | if _, ok := node.(*forest.Community); ok { 34 | return node 35 | } 36 | return nil 37 | }, func(a, b forest.Node) bool { 38 | return a.(*forest.Community).Created < b.(*forest.Community).Created 39 | }, func() []forest.Node { 40 | nodes, err = s.Recent(fields.NodeTypeCommunity, 1024) 41 | return nodes 42 | }, s) 43 | if err != nil { 44 | return nil, fmt.Errorf("failed initializing community list: %w", err) 45 | } 46 | return cl, nil 47 | 48 | } 49 | 50 | // IndexForID returns the position of the node with the given `id` inside of the CommunityList, 51 | // or -1 if it is not present. 52 | func (c *CommunityList) IndexForID(id *fields.QualifiedHash) int { 53 | return c.nodelist.IndexForID(id) 54 | } 55 | 56 | // WithCommunities executes an arbitrary closure with access to the communities stored 57 | // inside of the CommunitList. The closure must not modify the slice that it is 58 | // given. 59 | func (c *CommunityList) WithCommunities(closure func(communities []*forest.Community)) { 60 | c.nodelist.WithNodes(func(nodes []forest.Node) { 61 | c.communities = c.communities[:0] 62 | for _, node := range nodes { 63 | c.communities = append(c.communities, node.(*forest.Community)) 64 | } 65 | closure(c.communities) 66 | }) 67 | } 68 | 69 | // ReplyData holds the contents of a single reply and the major nodes that 70 | // it references. 71 | type ReplyData struct { 72 | ID *fields.QualifiedHash 73 | CommunityID *fields.QualifiedHash 74 | CommunityName string 75 | AuthorID *fields.QualifiedHash 76 | AuthorName string 77 | ParentID *fields.QualifiedHash 78 | ConversationID *fields.QualifiedHash 79 | Depth int 80 | CreatedAt time.Time 81 | Content string 82 | Metadata *twig.Data 83 | } 84 | 85 | // populate populates the the fields of a ReplyData object from a given node and a store. 86 | // It can be used on an unfilled ReplyData instance in place of a constructor. It returns 87 | // false if the node cannot be processed into ReplyData 88 | func (r *ReplyData) Populate(reply forest.Node, store store.ExtendedStore) bool { 89 | asReply, ok := reply.(*forest.Reply) 90 | if !ok { 91 | return false 92 | } 93 | // Verify twig data parses and node is not invisible 94 | md, err := asReply.TwigMetadata() 95 | if err != nil { 96 | // Malformed metadata 97 | return false 98 | } else if md.Contains("invisible", 1) { 99 | // Invisible message 100 | return false 101 | } 102 | 103 | r.Metadata = md 104 | r.ID = reply.ID() 105 | r.ConversationID = &asReply.ConversationID 106 | r.ParentID = &asReply.Parent 107 | r.AuthorID = &asReply.Author 108 | r.CommunityID = &asReply.CommunityID 109 | r.CreatedAt = asReply.CreatedAt() 110 | r.Content = string(asReply.Content.Blob) 111 | r.Depth = int(asReply.Depth) 112 | comm, has, err := store.GetCommunity(&asReply.CommunityID) 113 | 114 | if err != nil || !has { 115 | return false 116 | } 117 | asCommunity := comm.(*forest.Community) 118 | r.CommunityName = string(asCommunity.Name.Blob) 119 | 120 | author, has, err := store.GetIdentity(&asReply.Author) 121 | if err != nil || !has { 122 | return false 123 | } 124 | asAuthor := author.(*forest.Identity) 125 | r.AuthorName = string(asAuthor.Name.Blob) 126 | 127 | return true 128 | } 129 | 130 | // ensure ReplyData satisfies list.Element. 131 | var _ list.Element = ReplyData{} 132 | 133 | // Serial returns a unique identifier for this ReplyData which can be used for 134 | // dynamic list state management. 135 | func (r ReplyData) Serial() list.Serial { 136 | if r.ID != nil { 137 | return list.Serial(r.ID.String()) 138 | } 139 | return list.NoSerial 140 | } 141 | 142 | // NodeList implements a generic data structure for storing ordered lists of forest nodes. 143 | type NodeList struct { 144 | sync.RWMutex 145 | nodes []forest.Node 146 | filter NodeFilter 147 | sortFunc NodeSorter 148 | } 149 | 150 | type NodeFilter func(forest.Node) forest.Node 151 | type NodeSorter func(a, b forest.Node) bool 152 | 153 | // NewNodeList creates a nodelist subscribed to the provided store and initialized with the 154 | // return value of initialize(). The nodes will be sorted using the provided sort function 155 | // (via sort.Slice) and nodes will only be inserted into the list if the filter() function 156 | // returns non-nil for them. The filter function may transform the data before inserting it. 157 | // The filter function is also responsible for any deduplication. 158 | func NewNodeList(filter NodeFilter, sort NodeSorter, initialize func() []forest.Node, s store.ExtendedStore) *NodeList { 159 | nl := new(NodeList) 160 | nl.filter = filter 161 | nl.sortFunc = sort 162 | nl.withNodesWritable(func() { 163 | nl.subscribeTo(s) 164 | nl.insert(initialize()...) 165 | }) 166 | return nl 167 | } 168 | 169 | func (n *NodeList) Insert(nodes ...forest.Node) { 170 | n.withNodesWritable(func() { 171 | n.insert(nodes...) 172 | }) 173 | } 174 | 175 | func (n *NodeList) insert(nodes ...forest.Node) { 176 | outer: 177 | for _, node := range nodes { 178 | if filtered := n.filter(node); filtered != nil { 179 | for _, element := range n.nodes { 180 | if filtered.ID().Equals(element.ID()) { 181 | continue outer 182 | } 183 | } 184 | n.nodes = append(n.nodes, filtered) 185 | } 186 | } 187 | n.sort() 188 | } 189 | 190 | func (n *NodeList) subscribeTo(s store.ExtendedStore) { 191 | s.SubscribeToNewMessages(func(node forest.Node) { 192 | // cannot block in subscription 193 | go func() { 194 | n.Insert(node) 195 | }() 196 | }) 197 | } 198 | 199 | // WithNodes executes the provided closure with readonly access to the nodes managed 200 | // by the NodeList. This is the only way to view the nodes, and is thread-safe. 201 | func (n *NodeList) WithNodes(closure func(nodes []forest.Node)) { 202 | n.RLock() 203 | defer n.RUnlock() 204 | closure(n.nodes) 205 | } 206 | 207 | func (n *NodeList) withNodesWritable(closure func()) { 208 | n.Lock() 209 | defer n.Unlock() 210 | closure() 211 | } 212 | 213 | func (n *NodeList) sort() { 214 | sort.SliceStable(n.nodes, func(i, j int) bool { 215 | return n.sortFunc(n.nodes[i], n.nodes[j]) 216 | }) 217 | } 218 | 219 | // IndexForID returns the position of the node with the given `id` inside of the CommunityList, 220 | // or -1 if it is not present. 221 | func (n *NodeList) IndexForID(id *fields.QualifiedHash) int { 222 | n.RLock() 223 | defer n.RUnlock() 224 | for i, node := range n.nodes { 225 | if node.ID().Equals(id) { 226 | return i 227 | } 228 | } 229 | return -1 230 | } 231 | -------------------------------------------------------------------------------- /ds/reply-list.go: -------------------------------------------------------------------------------- 1 | package ds 2 | 3 | import ( 4 | "sort" 5 | "sync" 6 | 7 | "git.sr.ht/~whereswaldon/forest-go/fields" 8 | ) 9 | 10 | // sortable is a slice of reply data that conforms to the sort.Interface 11 | // and also tracks the index for each element in a separate map 12 | type sortable struct { 13 | initialized bool 14 | indexForID map[string]int 15 | data []ReplyData 16 | allow func(ReplyData) bool 17 | } 18 | 19 | var _ sort.Interface = &sortable{} 20 | 21 | func (s *sortable) initialize() { 22 | if s.initialized { 23 | return 24 | } 25 | s.initialized = true 26 | s.indexForID = make(map[string]int) 27 | } 28 | 29 | func (s *sortable) Len() int { 30 | return len(s.data) 31 | } 32 | 33 | func (s *sortable) ensureIndexed(i int) { 34 | s.indexForID[s.data[i].ID.String()] = i 35 | } 36 | 37 | func (s *sortable) Swap(i, j int) { 38 | s.initialize() 39 | s.data[i], s.data[j] = s.data[j], s.data[i] 40 | s.ensureIndexed(i) 41 | s.ensureIndexed(j) 42 | } 43 | 44 | func (s *sortable) Less(i, j int) bool { 45 | s.initialize() 46 | s.ensureIndexed(i) 47 | s.ensureIndexed(j) 48 | return s.data[i].CreatedAt.Before(s.data[j].CreatedAt) 49 | } 50 | 51 | func (s *sortable) IndexForID(id *fields.QualifiedHash) int { 52 | s.initialize() 53 | if out, ok := s.indexForID[id.String()]; !ok { 54 | return -1 55 | } else { 56 | return out 57 | } 58 | } 59 | 60 | func (s *sortable) Sort() { 61 | s.initialize() 62 | sort.Sort(s) 63 | } 64 | 65 | func (s *sortable) Contains(id *fields.QualifiedHash) bool { 66 | s.initialize() 67 | return s.IndexForID(id) != -1 68 | } 69 | 70 | func (s *sortable) shouldAllow(rd ReplyData) bool { 71 | if s.allow != nil { 72 | return s.allow(rd) 73 | } 74 | return true 75 | } 76 | 77 | func (s *sortable) Insert(nodes ...ReplyData) { 78 | s.initialize() 79 | var newNodes []ReplyData 80 | for _, node := range nodes { 81 | if s.shouldAllow(node) && !s.Contains(node.ID) { 82 | newNodes = append(newNodes, node) 83 | } 84 | } 85 | s.data = append(s.data, newNodes...) 86 | s.Sort() 87 | } 88 | 89 | // AlphaReplyList creates a thread-safe list of ReplyData that maintains its 90 | // internal sort order and supports looking up the index of specific nodes. 91 | // It enforces uniqueness on the nodes it contains 92 | type AlphaReplyList struct { 93 | sync.RWMutex 94 | sortable 95 | } 96 | 97 | func (r *AlphaReplyList) asWritable(f func()) { 98 | r.Lock() 99 | defer r.Unlock() 100 | f() 101 | } 102 | 103 | func (r *AlphaReplyList) asReadable(f func()) { 104 | r.RLock() 105 | defer r.RUnlock() 106 | f() 107 | } 108 | 109 | func (r *AlphaReplyList) FilterWith(f func(ReplyData) bool) { 110 | r.asWritable(func() { 111 | r.sortable.allow = f 112 | }) 113 | } 114 | 115 | // Insert adds the ReplyData to the list and updates the list sort order 116 | func (r *AlphaReplyList) Insert(nodes ...ReplyData) { 117 | r.asWritable(func() { 118 | r.sortable.Insert(nodes...) 119 | }) 120 | } 121 | 122 | // IndexForID returns the index at which the given ID's data is stored. 123 | // It is safe (and recommended) to call this function from within the function 124 | // passed to WithReplies(), as otherwise the node may by moved by another 125 | // goroutine between looking up its index and using it. 126 | func (r *AlphaReplyList) IndexForID(id *fields.QualifiedHash) (index int) { 127 | r.asReadable(func() { 128 | index = r.sortable.IndexForID(id) 129 | }) 130 | return 131 | } 132 | 133 | // Contains returns whether the list currently contains the node with the given 134 | // ID. 135 | func (r *AlphaReplyList) Contains(id *fields.QualifiedHash) (isContained bool) { 136 | r.asReadable(func() { 137 | isContained = r.sortable.Contains(id) 138 | }) 139 | return 140 | } 141 | 142 | // WithReplies accepts a closure that it will run with access to the stored list 143 | // of replies. It is invalid to access the replies list stored by a replyList 144 | // except from within this closure. References to the slice are not valid after 145 | // the closure returns, and using them will cause confusing bugs. 146 | func (r *AlphaReplyList) WithReplies(f func(replies []ReplyData)) { 147 | r.asReadable(func() { 148 | f(r.data) 149 | }) 150 | } 151 | -------------------------------------------------------------------------------- /ds/trackers.go: -------------------------------------------------------------------------------- 1 | package ds 2 | 3 | import ( 4 | "fmt" 5 | "sync" 6 | 7 | forest "git.sr.ht/~whereswaldon/forest-go" 8 | "git.sr.ht/~whereswaldon/forest-go/fields" 9 | "git.sr.ht/~whereswaldon/forest-go/store" 10 | ) 11 | 12 | // HiddenTracker tracks which nodes have been manually hidden by a user. 13 | // This is modeled as a set of "anchor" nodes, the desendants of which 14 | // are not visible. Anchors themselves are visible, and can be used to 15 | // reveal their descendants. HiddenTracker is safe for concurrent use. 16 | type HiddenTracker struct { 17 | sync.RWMutex 18 | anchors map[string][]*fields.QualifiedHash 19 | hidden IDSet 20 | } 21 | 22 | // init initializes the underlying data structures. 23 | func (h *HiddenTracker) init() { 24 | if h.anchors == nil { 25 | h.anchors = make(map[string][]*fields.QualifiedHash) 26 | } 27 | } 28 | 29 | // IsHidden returns whether the provided node should be hidden. 30 | func (h *HiddenTracker) IsHidden(id *fields.QualifiedHash) bool { 31 | h.RLock() 32 | defer h.RUnlock() 33 | return h.isHidden(id) 34 | } 35 | 36 | func (h *HiddenTracker) isHidden(id *fields.QualifiedHash) bool { 37 | return h.hidden.Contains(id) 38 | } 39 | 40 | // IsAnchor returns whether the provided node is serving as an anchor 41 | // that hides its descendants. 42 | func (h *HiddenTracker) IsAnchor(id *fields.QualifiedHash) bool { 43 | h.RLock() 44 | defer h.RUnlock() 45 | return h.isAnchor(id) 46 | } 47 | 48 | func (h *HiddenTracker) isAnchor(id *fields.QualifiedHash) bool { 49 | _, ok := h.anchors[id.String()] 50 | return ok 51 | } 52 | 53 | // NumDescendants returns the number of hidden descendants for the given anchor 54 | // node. 55 | func (h *HiddenTracker) NumDescendants(id *fields.QualifiedHash) int { 56 | h.RLock() 57 | defer h.RUnlock() 58 | return h.numDescendants(id) 59 | } 60 | 61 | func (h *HiddenTracker) numDescendants(id *fields.QualifiedHash) int { 62 | return len(h.anchors[id.String()]) 63 | } 64 | 65 | // ToggleAnchor switches the anchor state of the given ID. 66 | func (h *HiddenTracker) ToggleAnchor(id *fields.QualifiedHash, s store.ExtendedStore) error { 67 | h.Lock() 68 | defer h.Unlock() 69 | return h.toggleAnchor(id, s) 70 | } 71 | 72 | func (h *HiddenTracker) toggleAnchor(id *fields.QualifiedHash, s store.ExtendedStore) error { 73 | if h.isAnchor(id) { 74 | h.reveal(id) 75 | return nil 76 | } 77 | return h.hide(id, s) 78 | } 79 | 80 | // Hide makes the given ID into an anchor and hides its descendants. 81 | func (h *HiddenTracker) Hide(id *fields.QualifiedHash, s store.ExtendedStore) error { 82 | h.Lock() 83 | defer h.Unlock() 84 | return h.hide(id, s) 85 | } 86 | 87 | func (h *HiddenTracker) hide(id *fields.QualifiedHash, s store.ExtendedStore) error { 88 | h.init() 89 | descendants, err := s.DescendantsOf(id) 90 | if err != nil { 91 | return fmt.Errorf("failed looking up descendants of %s: %w", id.String(), err) 92 | } 93 | // ensure that any descendants that were previously hidden are subsumed by 94 | // hiding their ancestor. 95 | for _, d := range descendants { 96 | if _, ok := h.anchors[d.String()]; ok { 97 | delete(h.anchors, d.String()) 98 | } 99 | } 100 | h.anchors[id.String()] = descendants 101 | h.hidden.Add(descendants...) 102 | return nil 103 | } 104 | 105 | // Process ensures that the internal state of the HiddenTracker accounts 106 | // for the provided node. This is primarily useful for nodes that were inserted 107 | // into the store *after* their ancestor was made into an anchor. Each time 108 | // a new node is received, it should be Process()ed. 109 | func (h *HiddenTracker) Process(node forest.Node) { 110 | h.Lock() 111 | defer h.Unlock() 112 | h.process(node) 113 | } 114 | 115 | func (h *HiddenTracker) process(node forest.Node) { 116 | if h.isHidden(node.ParentID()) || h.isAnchor(node.ParentID()) { 117 | h.hidden.Add(node.ID()) 118 | } 119 | } 120 | 121 | // Reveal makes the given node no longer an anchor, thereby un-hiding all 122 | // of its children. 123 | func (h *HiddenTracker) Reveal(id *fields.QualifiedHash) { 124 | h.Lock() 125 | defer h.Unlock() 126 | h.Reveal(id) 127 | } 128 | 129 | func (h *HiddenTracker) reveal(id *fields.QualifiedHash) { 130 | h.init() 131 | descendants, ok := h.anchors[id.String()] 132 | if !ok { 133 | return 134 | } 135 | h.hidden.Remove(descendants...) 136 | delete(h.anchors, id.String()) 137 | } 138 | 139 | // IDSet implements basic set operations on node IDs. It is not safe for 140 | // concurrent use. 141 | type IDSet struct { 142 | contents map[string]struct{} 143 | } 144 | 145 | // init allocates the underlying map type. 146 | func (h *IDSet) init() { 147 | h.contents = make(map[string]struct{}) 148 | } 149 | 150 | // Add inserts the list of IDs into the set. 151 | func (h *IDSet) Add(ids ...*fields.QualifiedHash) { 152 | if h.contents == nil { 153 | h.init() 154 | } 155 | for _, id := range ids { 156 | h.contents[id.String()] = struct{}{} 157 | } 158 | } 159 | 160 | // Contains returns whether the given ID is in the set. 161 | func (h *IDSet) Contains(id *fields.QualifiedHash) bool { 162 | if h.contents == nil { 163 | h.init() 164 | } 165 | _, contains := h.contents[id.String()] 166 | return contains 167 | } 168 | 169 | // Remove deletes the provided IDs from the set. 170 | func (h *IDSet) Remove(ids ...*fields.QualifiedHash) { 171 | if h.contents == nil { 172 | h.init() 173 | } 174 | for _, id := range ids { 175 | if h.Contains(id) { 176 | delete(h.contents, id.String()) 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module git.sr.ht/~whereswaldon/sprig 2 | 3 | go 1.18 4 | 5 | require ( 6 | gioui.org v0.4.2 7 | gioui.org/cmd v0.0.0-20240115171100-84ca391d514b 8 | gioui.org/x v0.4.0 9 | git.sr.ht/~athorp96/forest-ex v0.0.0-20210604181634-7063d1aadd25 10 | git.sr.ht/~gioverse/chat v0.0.0-20220607180414-f0addfc0d932 11 | git.sr.ht/~whereswaldon/forest-go v0.0.0-20230530191337-133031baad4c 12 | git.sr.ht/~whereswaldon/latest v0.0.0-20210304001450-aafd2a13a1bb 13 | git.sr.ht/~whereswaldon/sprout-go v0.0.0-20220128205300-c2f66369262c 14 | github.com/inkeliz/giohyperlink v0.0.0-20210728190223-81136d95d4bb 15 | github.com/magefile/mage v1.10.0 16 | github.com/pkg/profile v1.6.0 17 | golang.org/x/crypto v0.0.0-20210921155107-089bfa567519 18 | golang.org/x/exp/shiny v0.0.0-20220827204233-334a2380cb91 19 | ) 20 | 21 | require ( 22 | gioui.org/cpu v0.0.0-20210817075930-8d6a761490d2 // indirect 23 | gioui.org/shader v1.0.8 // indirect 24 | git.sr.ht/~jackmordaunt/go-toast v1.0.0 // indirect 25 | git.wow.st/gmp/jni v0.0.0-20210610011705-34026c7e22d0 // indirect 26 | github.com/akavel/rsrc v0.10.1 // indirect 27 | github.com/esiqveland/notify v0.11.0 // indirect 28 | github.com/go-ole/go-ole v1.2.6 // indirect 29 | github.com/go-text/typesetting v0.0.0-20230803102845-24e03d8b5372 // indirect 30 | github.com/godbus/dbus/v5 v5.0.6 // indirect 31 | github.com/shamaton/msgpack v1.2.1 // indirect 32 | github.com/yuin/goldmark v1.4.13 // indirect 33 | go.etcd.io/bbolt v1.3.6 // indirect 34 | golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect 35 | golang.org/x/image v0.15.0 // indirect 36 | golang.org/x/mod v0.14.0 // indirect 37 | golang.org/x/sync v0.6.0 // indirect 38 | golang.org/x/sys v0.16.0 // indirect 39 | golang.org/x/text v0.14.0 // indirect 40 | golang.org/x/tools v0.17.0 // indirect 41 | ) 42 | 43 | replace golang.org/x/crypto => github.com/ProtonMail/crypto v0.0.0-20200605105621-11f6ee2dd602 44 | -------------------------------------------------------------------------------- /icons/icons.go: -------------------------------------------------------------------------------- 1 | package icons 2 | 3 | import ( 4 | "gioui.org/widget" 5 | "golang.org/x/exp/shiny/materialdesign/icons" 6 | ) 7 | 8 | var BackIcon *widget.Icon = func() *widget.Icon { 9 | icon, _ := widget.NewIcon(icons.NavigationArrowBack) 10 | return icon 11 | }() 12 | 13 | var ForwardIcon *widget.Icon = func() *widget.Icon { 14 | icon, _ := widget.NewIcon(icons.NavigationArrowForward) 15 | return icon 16 | }() 17 | 18 | var RefreshIcon *widget.Icon = func() *widget.Icon { 19 | icon, _ := widget.NewIcon(icons.NavigationRefresh) 20 | return icon 21 | }() 22 | 23 | var ClearIcon *widget.Icon = func() *widget.Icon { 24 | icon, _ := widget.NewIcon(icons.ContentClear) 25 | return icon 26 | }() 27 | 28 | var ReplyIcon *widget.Icon = func() *widget.Icon { 29 | icon, _ := widget.NewIcon(icons.ContentReply) 30 | return icon 31 | }() 32 | 33 | var CancelReplyIcon *widget.Icon = func() *widget.Icon { 34 | icon, _ := widget.NewIcon(icons.NavigationCancel) 35 | return icon 36 | }() 37 | 38 | var SendReplyIcon *widget.Icon = func() *widget.Icon { 39 | icon, _ := widget.NewIcon(icons.ContentSend) 40 | return icon 41 | }() 42 | 43 | var CreateConversationIcon *widget.Icon = func() *widget.Icon { 44 | icon, _ := widget.NewIcon(icons.ContentAdd) 45 | return icon 46 | }() 47 | 48 | var CopyIcon *widget.Icon = func() *widget.Icon { 49 | icon, _ := widget.NewIcon(icons.ContentContentCopy) 50 | return icon 51 | }() 52 | 53 | var PasteIcon *widget.Icon = func() *widget.Icon { 54 | icon, _ := widget.NewIcon(icons.ContentContentPaste) 55 | return icon 56 | }() 57 | 58 | var FilterIcon *widget.Icon = func() *widget.Icon { 59 | icon, _ := widget.NewIcon(icons.ContentFilterList) 60 | return icon 61 | }() 62 | 63 | var MenuIcon *widget.Icon = func() *widget.Icon { 64 | icon, _ := widget.NewIcon(icons.NavigationMenu) 65 | return icon 66 | }() 67 | 68 | var ServerIcon *widget.Icon = func() *widget.Icon { 69 | icon, _ := widget.NewIcon(icons.ActionDNS) 70 | return icon 71 | }() 72 | 73 | var SettingsIcon *widget.Icon = func() *widget.Icon { 74 | icon, _ := widget.NewIcon(icons.ActionSettings) 75 | return icon 76 | }() 77 | 78 | var ChatIcon *widget.Icon = func() *widget.Icon { 79 | icon, _ := widget.NewIcon(icons.CommunicationChat) 80 | return icon 81 | }() 82 | 83 | var IdentityIcon *widget.Icon = func() *widget.Icon { 84 | icon, _ := widget.NewIcon(icons.ActionPermIdentity) 85 | return icon 86 | }() 87 | 88 | var SubscriptionIcon *widget.Icon = func() *widget.Icon { 89 | icon, _ := widget.NewIcon(icons.CommunicationImportContacts) 90 | return icon 91 | }() 92 | 93 | var CollapseIcon *widget.Icon = func() *widget.Icon { 94 | icon, _ := widget.NewIcon(icons.NavigationUnfoldLess) 95 | return icon 96 | }() 97 | 98 | var ExpandIcon *widget.Icon = func() *widget.Icon { 99 | icon, _ := widget.NewIcon(icons.NavigationUnfoldMore) 100 | return icon 101 | }() 102 | -------------------------------------------------------------------------------- /identity-form.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/unit" 6 | "gioui.org/widget" 7 | "gioui.org/widget/material" 8 | materials "gioui.org/x/component" 9 | "git.sr.ht/~whereswaldon/sprig/core" 10 | sprigWidget "git.sr.ht/~whereswaldon/sprig/widget" 11 | ) 12 | 13 | type IdentityFormView struct { 14 | manager ViewManager 15 | sprigWidget.TextForm 16 | CreateButton widget.Clickable 17 | 18 | core.App 19 | } 20 | 21 | var _ View = &IdentityFormView{} 22 | 23 | func NewIdentityFormView(app core.App) View { 24 | c := &IdentityFormView{ 25 | App: app, 26 | } 27 | c.TextForm.TextField.Editor.SingleLine = true 28 | 29 | return c 30 | } 31 | 32 | func (c *IdentityFormView) HandleIntent(intent Intent) {} 33 | 34 | func (c *IdentityFormView) BecomeVisible() { 35 | } 36 | 37 | func (c *IdentityFormView) NavItem() *materials.NavItem { 38 | return nil 39 | } 40 | 41 | func (c *IdentityFormView) AppBarData() (bool, string, []materials.AppBarAction, []materials.OverflowAction) { 42 | return false, "", nil, nil 43 | } 44 | 45 | func (c *IdentityFormView) HandleClipboard(contents string) { 46 | } 47 | 48 | func (c *IdentityFormView) Update(gtx layout.Context) { 49 | if c.CreateButton.Clicked(gtx) { 50 | c.Settings().CreateIdentity(c.TextField.Text()) 51 | c.manager.RequestViewSwitch(SubscriptionSetupFormViewID) 52 | } 53 | } 54 | 55 | func (c *IdentityFormView) Layout(gtx layout.Context) layout.Dimensions { 56 | theme := c.Theme().Current().Theme 57 | return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 58 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 59 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 60 | return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 61 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, 62 | material.Body1(theme, "Your Arbor Username:").Layout, 63 | ) 64 | }) 65 | }), 66 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 67 | return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 68 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D { 69 | return c.TextField.Layout(gtx, theme, "Username") 70 | }) 71 | }) 72 | }), 73 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 74 | return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 75 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, 76 | material.Body2(theme, "Your username is public, and cannot currently be changed once it is chosen.").Layout, 77 | ) 78 | }) 79 | }), 80 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 81 | return layout.Center.Layout(gtx, func(gtx layout.Context) layout.Dimensions { 82 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, 83 | material.Button(theme, &(c.CreateButton), "Create").Layout, 84 | ) 85 | }) 86 | }), 87 | ) 88 | }) 89 | } 90 | 91 | func (c *IdentityFormView) SetManager(mgr ViewManager) { 92 | c.manager = mgr 93 | } 94 | -------------------------------------------------------------------------------- /img/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arborchat/sprig/23645d553dd791d8c37ebb86dada716a32e5e6f7/img/screenshot.png -------------------------------------------------------------------------------- /install-linux.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | BASEDIR=$(dirname "$(realpath "$0")") 6 | 7 | PREFIX=${PREFIX:-/usr/local} 8 | BIN_DIR=$PREFIX/bin 9 | APP_DIR=$PREFIX/share/applications 10 | ICON_DIR=$PREFIX/share/icons 11 | 12 | if [ ! -d "$BIN_DIR" ]; then 13 | mkdir -pv "$BIN_DIR" 14 | fi 15 | install "$BASEDIR/sprig" "$PREFIX/bin/sprig" && echo "$PREFIX/bin/sprig" 16 | 17 | if [ ! -d "$APP_DIR" ]; then 18 | mkdir -pv "$APP_DIR" 19 | fi 20 | # Update icon path in desktop entry 21 | sed -i "s#{ICON_PATH}#$ICON_DIR#" "$BASEDIR/desktop-assets/sprig.desktop" 22 | cp -v "$BASEDIR/desktop-assets/sprig.desktop" "$PREFIX/share/applications/" 23 | 24 | if [ ! -d "$ICON_DIR" ]; then 25 | mkdir -pv "$ICON_DIR" 26 | fi 27 | cp -v "$BASEDIR/appicon.png" "$PREFIX/share/icons/sprig.png" 28 | -------------------------------------------------------------------------------- /intent.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | type Intent struct { 4 | ID IntentID 5 | Details interface{} 6 | } 7 | 8 | type IntentID = string 9 | 10 | const ( 11 | ViewReplyWithID IntentID = "view-reply-with-id" 12 | ) 13 | 14 | type ViewReplyWithIDDetails struct { 15 | NodeID string 16 | } 17 | -------------------------------------------------------------------------------- /magefile.go: -------------------------------------------------------------------------------- 1 | // +build mage 2 | 3 | package main 4 | 5 | import ( 6 | "archive/zip" 7 | "io/ioutil" 8 | "os" 9 | 10 | "github.com/magefile/mage/mg" 11 | "github.com/magefile/mage/sh" 12 | ) 13 | 14 | var LINUX_BIN = "sprig" 15 | var LINUX_ARCHIVE = "sprig-linux.tar.xz" 16 | var WINDOWS_BIN = "sprig.exe" 17 | var WINDOWS_ARCHIVE = "sprig-windows.zip" 18 | var FPNAME = "chat.arbor.Client.Sprig" 19 | var FPCONFIG = FPNAME + ".yml" 20 | var FPBUILD = "pakbuild" 21 | var FPREPO = "/data/fp-repo" 22 | 23 | var Aliases = map[string]interface{}{ 24 | "c": Clean, 25 | "l": Linux, 26 | "w": Windows, 27 | "fp": Flatpak, 28 | "run": FlatpakRun, 29 | } 30 | 31 | func goFlags() string { 32 | return "-ldflags=-X=main.Version=" + embeddedVersion() 33 | } 34 | 35 | func embeddedVersion() string { 36 | gitVersion, err := sh.Output("git", "describe", "--tags", "--dirty", "--always") 37 | if err != nil { 38 | return "git" 39 | } 40 | return gitVersion 41 | } 42 | 43 | // Build all binary targets 44 | func All() { 45 | mg.Deps(Linux, Windows) 46 | } 47 | 48 | // Build for specific platforms with a given binary name. 49 | func BuildFor(platform, binary string) error { 50 | _, err := sh.Exec(map[string]string{"GOOS": platform, "GOFLAGS": goFlags()}, 51 | os.Stdout, os.Stderr, "go", "build", "-o", binary, ".") 52 | if err != nil { 53 | return err 54 | } 55 | return nil 56 | } 57 | 58 | // Build Linux 59 | func LinuxBin() error { 60 | return BuildFor("linux", LINUX_BIN) 61 | } 62 | 63 | // Build Linux and archive/compress binary 64 | func Linux() error { 65 | mg.Deps(LinuxBin) 66 | return sh.Run("tar", "-cJf", LINUX_ARCHIVE, LINUX_BIN, "desktop-assets", "install-linux.sh", "appicon.png", "LICENSE.txt") 67 | } 68 | 69 | // Build Windows 70 | func WindowsBin() error { 71 | return BuildFor("windows", WINDOWS_BIN) 72 | } 73 | 74 | // Build Windows binary and zip it up 75 | func Windows() error { 76 | mg.Deps(WindowsBin) 77 | file, err := os.Create(WINDOWS_ARCHIVE) 78 | if err != nil { 79 | return err 80 | } 81 | zipWriter := zip.NewWriter(file) 82 | f, err := zipWriter.Create(WINDOWS_BIN) 83 | if err != nil { 84 | return err 85 | } 86 | body, err := ioutil.ReadFile(WINDOWS_BIN) 87 | if err != nil { 88 | return err 89 | } 90 | _, err = f.Write(body) 91 | if err != nil { 92 | return err 93 | } 94 | err = zipWriter.Close() 95 | if err != nil { 96 | return err 97 | } 98 | return nil 99 | } 100 | 101 | // Build flatpak 102 | func Flatpak() error { 103 | mg.Deps(FlatpakInit) 104 | return sh.Run("flatpak-builder", "--user", "--force-clean", FPBUILD, FPCONFIG) 105 | } 106 | 107 | // Get a shell within flatpak 108 | func FlatpakShell() error { 109 | mg.Deps(FlatpakInit) 110 | return sh.Run("flatpak-builder", "--user", "--run", FPBUILD, FPCONFIG, "sh") 111 | } 112 | 113 | // Install flatpak 114 | func FlatpakInstall() error { 115 | mg.Deps(FlatpakInit) 116 | return sh.Run("flatpak-builder", "--user", "--install", "--force-clean", FPBUILD, FPCONFIG) 117 | } 118 | 119 | // Run flatpak 120 | func FlatpakRun() error { 121 | return sh.Run("flatpak", "run", FPNAME) 122 | } 123 | 124 | // Flatpak into repo 125 | func FlatpakRepo() error { 126 | return sh.Run("flatpak-builder", "--user", "--force-clean", "--repo="+FPREPO, FPBUILD, FPCONFIG) 127 | } 128 | 129 | // Enable repos if this is your first time running flatpak 130 | func FlatpakInit() error { 131 | err := sh.RunV("flatpak", "remote-add", "--user", "--if-not-exists", "flathub", "https://flathub.org/repo/flathub.flatpakrepo") 132 | if err != nil { 133 | return err 134 | } 135 | err = sh.Run("flatpak", "install", "--user", "flathub", "org.freedesktop.Sdk/x86_64/19.08") 136 | if err != nil { 137 | return err 138 | } 139 | err = sh.Run("flatpak", "install", "--user", "flathub", "org.freedesktop.Platform/x86_64/19.08") 140 | if err != nil { 141 | return err 142 | } 143 | return nil 144 | } 145 | 146 | // Clean up 147 | func Clean() error { 148 | return sh.Run("rm", "-rf", WINDOWS_ARCHIVE, WINDOWS_BIN, LINUX_ARCHIVE, LINUX_BIN, FPBUILD) 149 | } 150 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "log" 6 | "os" 7 | "os/signal" 8 | "path/filepath" 9 | 10 | "gioui.org/app" 11 | "gioui.org/f32" 12 | "gioui.org/io/key" 13 | "gioui.org/io/system" 14 | "gioui.org/layout" 15 | "gioui.org/op" 16 | "gioui.org/x/profiling" 17 | "git.sr.ht/~whereswaldon/sprig/core" 18 | sprigTheme "git.sr.ht/~whereswaldon/sprig/widget/theme" 19 | "github.com/inkeliz/giohyperlink" 20 | "github.com/pkg/profile" 21 | ) 22 | 23 | type ( 24 | C = layout.Context 25 | D = layout.Dimensions 26 | ) 27 | 28 | func main() { 29 | log.SetFlags(log.Flags() | log.Lshortfile) 30 | go func() { 31 | w := app.NewWindow(app.Title("Sprig")) 32 | if err := eventLoop(w); err != nil { 33 | log.Fatalf("exiting due to error: %v", err) 34 | } 35 | os.Exit(0) 36 | }() 37 | app.Main() 38 | } 39 | 40 | func eventLoop(w *app.Window) error { 41 | var ( 42 | dataDir string 43 | invalidate bool 44 | profileOpt string 45 | ) 46 | 47 | dataDir, err := getDataDir("sprig") 48 | if err != nil { 49 | log.Printf("finding application data dir: %v", err) 50 | } 51 | 52 | flag.StringVar(&profileOpt, "profile", "none", "create the provided kind of profile. Use one of [none, cpu, mem, block, goroutine, mutex, trace, gio]") 53 | flag.BoolVar(&invalidate, "invalidate", false, "invalidate every single frame, only useful for profiling") 54 | flag.StringVar(&dataDir, "data-dir", dataDir, "application state directory") 55 | flag.Parse() 56 | 57 | profiler := ProfileOpt(profileOpt).NewProfiler() 58 | profiler.Start() 59 | defer profiler.Stop() 60 | 61 | app, err := core.NewApp(w, dataDir) 62 | if err != nil { 63 | log.Fatalf("Failed initializing application: %v", err) 64 | } 65 | 66 | go app.Arbor().StartHeartbeat() 67 | 68 | // handle ctrl+c to shutdown 69 | sigs := make(chan os.Signal, 1) 70 | signal.Notify(sigs, os.Interrupt) 71 | 72 | vm := NewViewManager(w, app) 73 | vm.ApplySettings(app.Settings()) 74 | vm.RegisterView(ReplyViewID, NewReplyListView(app)) 75 | vm.RegisterView(ConnectFormID, NewConnectFormView(app)) 76 | vm.RegisterView(SubscriptionViewID, NewSubscriptionView(app)) 77 | vm.RegisterView(SettingsID, NewCommunityMenuView(app)) 78 | vm.RegisterView(IdentityFormID, NewIdentityFormView(app)) 79 | vm.RegisterView(ConsentViewID, NewConsentView(app)) 80 | vm.RegisterView(SubscriptionSetupFormViewID, NewSubSetupFormView(app)) 81 | vm.RegisterView(DynamicChatViewID, NewDynamicChatView(app)) 82 | 83 | if app.Settings().AcknowledgedNoticeVersion() < NoticeVersion { 84 | vm.SetView(ConsentViewID) 85 | } else if app.Settings().Address() == "" { 86 | vm.SetView(ConnectFormID) 87 | } else if app.Settings().ActiveArborIdentityID() == nil { 88 | vm.SetView(IdentityFormID) 89 | } else if len(app.Settings().Subscriptions()) < 1 { 90 | vm.SetView(SubscriptionSetupFormViewID) 91 | } else { 92 | vm.SetView(ReplyViewID) 93 | } 94 | 95 | go func() { 96 | <-sigs 97 | w.Perform(system.ActionClose) 98 | }() 99 | 100 | var ops op.Ops 101 | for { 102 | ev := w.NextEvent() 103 | giohyperlink.ListenEvents(ev) 104 | switch event := ev.(type) { 105 | case system.DestroyEvent: 106 | app.Shutdown() 107 | return event.Err 108 | case system.FrameEvent: 109 | gtx := layout.NewContext(&ops, event) 110 | if profiler.Recorder != nil { 111 | profiler.Record(gtx) 112 | } 113 | if invalidate { 114 | op.InvalidateOp{}.Add(gtx.Ops) 115 | } 116 | for _, event := range gtx.Events(w) { 117 | if ke, ok := event.(key.Event); ok && ke.Name == key.NameBack { 118 | vm.HandleBackNavigation() 119 | } 120 | } 121 | key.InputOp{ 122 | Tag: w, 123 | Keys: key.Set(key.NameBack), 124 | }.Add(gtx.Ops) 125 | th := app.Theme().Current() 126 | layout.Stack{}.Layout(gtx, 127 | layout.Expanded(func(gtx C) D { 128 | return sprigTheme.Rect{ 129 | Color: th.Background.Dark.Bg, 130 | Size: f32.Point{ 131 | X: float32(gtx.Constraints.Max.X), 132 | Y: float32(gtx.Constraints.Max.Y), 133 | }, 134 | }.Layout(gtx) 135 | }), 136 | layout.Stacked(func(gtx C) D { 137 | return layout.Stack{}.Layout(gtx, 138 | layout.Expanded(func(gtx C) D { 139 | return sprigTheme.Rect{ 140 | Color: th.Background.Dark.Bg, 141 | Size: f32.Point{ 142 | X: float32(gtx.Constraints.Max.X), 143 | Y: float32(gtx.Constraints.Max.Y), 144 | }, 145 | }.Layout(gtx) 146 | }), 147 | layout.Stacked(vm.Layout), 148 | ) 149 | }), 150 | ) 151 | event.Frame(gtx.Ops) 152 | default: 153 | ProcessPlatformEvent(app, event) 154 | } 155 | } 156 | } 157 | 158 | type ViewID int 159 | 160 | const ( 161 | ConnectFormID ViewID = iota 162 | IdentityFormID 163 | SettingsID 164 | ReplyViewID 165 | ConsentViewID 166 | SubscriptionViewID 167 | SubscriptionSetupFormViewID 168 | DynamicChatViewID 169 | ) 170 | 171 | // getDataDir returns application specific file directory to use for storage. 172 | // Suffix is joined to the path for convenience. 173 | func getDataDir(suffix string) (string, error) { 174 | d, err := app.DataDir() 175 | if err != nil { 176 | return "", err 177 | } 178 | return filepath.Join(d, suffix), nil 179 | } 180 | 181 | // Profiler unifies the profiling api between Gio profiler and pkg/profile. 182 | type Profiler struct { 183 | Starter func(p *profile.Profile) 184 | Stopper func() 185 | Recorder func(gtx C) 186 | } 187 | 188 | // Start profiling. 189 | func (pfn *Profiler) Start() { 190 | if pfn.Starter != nil { 191 | pfn.Stopper = profile.Start(pfn.Starter).Stop 192 | } 193 | } 194 | 195 | // Stop profiling. 196 | func (pfn *Profiler) Stop() { 197 | if pfn.Stopper != nil { 198 | pfn.Stopper() 199 | } 200 | } 201 | 202 | // Record GUI stats per frame. 203 | func (pfn Profiler) Record(gtx C) { 204 | if pfn.Recorder != nil { 205 | pfn.Recorder(gtx) 206 | } 207 | } 208 | 209 | // ProfileOpt specifies the various profiling options. 210 | type ProfileOpt string 211 | 212 | const ( 213 | None ProfileOpt = "none" 214 | CPU ProfileOpt = "cpu" 215 | Memory ProfileOpt = "mem" 216 | Block ProfileOpt = "block" 217 | Goroutine ProfileOpt = "goroutine" 218 | Mutex ProfileOpt = "mutex" 219 | Trace ProfileOpt = "trace" 220 | Gio ProfileOpt = "gio" 221 | ) 222 | 223 | // NewProfiler creates a profiler based on the selected option. 224 | func (p ProfileOpt) NewProfiler() Profiler { 225 | switch p { 226 | case "", None: 227 | return Profiler{} 228 | case CPU: 229 | return Profiler{Starter: profile.CPUProfile} 230 | case Memory: 231 | return Profiler{Starter: profile.MemProfile} 232 | case Block: 233 | return Profiler{Starter: profile.BlockProfile} 234 | case Goroutine: 235 | return Profiler{Starter: profile.GoroutineProfile} 236 | case Mutex: 237 | return Profiler{Starter: profile.MutexProfile} 238 | case Trace: 239 | return Profiler{Starter: profile.TraceProfile} 240 | case Gio: 241 | var ( 242 | recorder *profiling.CSVTimingRecorder 243 | err error 244 | ) 245 | return Profiler{ 246 | Starter: func(*profile.Profile) { 247 | recorder, err = profiling.NewRecorder(nil) 248 | if err != nil { 249 | log.Printf("starting profiler: %v", err) 250 | } 251 | }, 252 | Stopper: func() { 253 | if recorder == nil { 254 | return 255 | } 256 | if err := recorder.Stop(); err != nil { 257 | log.Printf("stopping profiler: %v", err) 258 | } 259 | }, 260 | Recorder: func(gtx C) { 261 | if recorder == nil { 262 | return 263 | } 264 | recorder.Profile(gtx) 265 | }, 266 | } 267 | } 268 | return Profiler{} 269 | } 270 | -------------------------------------------------------------------------------- /platform/desktop.go: -------------------------------------------------------------------------------- 1 | //+build !ios,!android 2 | 3 | package platform 4 | 5 | const Mobile = false 6 | -------------------------------------------------------------------------------- /platform/mobile.go: -------------------------------------------------------------------------------- 1 | //+build ios android 2 | 3 | package platform 4 | 5 | const Mobile = true 6 | -------------------------------------------------------------------------------- /platform_android.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | gioapp "gioui.org/app" 5 | "gioui.org/io/event" 6 | "git.sr.ht/~whereswaldon/sprig/core" 7 | ) 8 | 9 | func ProcessPlatformEvent(app core.App, e event.Event) bool { 10 | switch e := e.(type) { 11 | case gioapp.ViewEvent: 12 | app.Haptic().UpdateAndroidViewRef(e.View) 13 | return true 14 | default: 15 | return false 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /platform_other.go: -------------------------------------------------------------------------------- 1 | //+build !android 2 | 3 | package main 4 | 5 | import ( 6 | "gioui.org/io/event" 7 | "git.sr.ht/~whereswaldon/sprig/core" 8 | ) 9 | 10 | func ProcessPlatformEvent(app core.App, e event.Event) bool { 11 | return false 12 | } 13 | -------------------------------------------------------------------------------- /reply-view_desktop.go: -------------------------------------------------------------------------------- 1 | //+build !ios,!android 2 | 3 | package main 4 | 5 | func (r *ReplyListView) requestKeyboardFocus() { 6 | // on desktop, actually request keyboard focus 7 | r.ShouldRequestKeyboardFocus = true 8 | } 9 | 10 | // submitShouldSend indicates whether a submit event from the reply editor 11 | // should automatically send the message. 12 | const submitShouldSend = true 13 | -------------------------------------------------------------------------------- /reply-view_mobile.go: -------------------------------------------------------------------------------- 1 | //+build ios android 2 | 3 | package main 4 | 5 | func (r *ReplyListView) requestKeyboardFocus() { 6 | // do nothing on mobile, otherwise we trigger the on-screen keyboard 7 | } 8 | 9 | // submitShouldSend indicates whether a submit event from the reply editor 10 | // should automatically send the message. 11 | const submitShouldSend = false 12 | -------------------------------------------------------------------------------- /scripts/macos-poll-and-build.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -euo pipefail 4 | 5 | SCRIPT_DIR=$(cd $(dirname "$0") && pwd) 6 | REPO_ROOT="$SCRIPT_DIR/sprig" 7 | ASSET_ROOT="$SCRIPT_DIR/assets" 8 | 9 | function build_for_mac() { 10 | local -r artifact=$1 11 | make macos 12 | mv sprig-mac.tar.gz "$ASSET_ROOT/$artifact" 13 | } 14 | 15 | function build_for_tag() { 16 | local -r tag="$1" 17 | artifact="sprig-$tag-macOS.tar.gz" 18 | # check if we are on a new tagged commit 19 | if ! [ -e "$ASSET_ROOT/$artifact" ]; then 20 | echo "building tag $tag" 21 | if ! build_for_mac "$artifact"; then 22 | return 1 23 | fi 24 | if ! curl --http1.2 -H "Authorization: token $SRHT_TOKEN" \ 25 | -F "file=@$ASSET_ROOT/$artifact" "https://git.sr.ht/api/repos/sprig/artifacts/$tag" ; then 26 | echo "upload failed" 27 | return 2 28 | fi 29 | fi 30 | } 31 | 32 | if ! [ -d sprig ]; then git clone https://git.sr.ht/~whereswaldon/sprig; fi 33 | 34 | # poll indefinitely 35 | while true; do 36 | cd "$REPO_ROOT" 37 | # update our repo 38 | git fetch --tags 39 | 40 | # check if we're on a tag 41 | for tag in $(git tag); do 42 | git checkout "$tag" 43 | if ! build_for_tag "$tag"; then echo "failed building for tag $tag"; fi 44 | done 45 | 46 | # sleep 15 minutes 47 | sleep 900 48 | done 49 | -------------------------------------------------------------------------------- /settings-view.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/unit" 8 | "gioui.org/widget" 9 | "gioui.org/widget/material" 10 | "gioui.org/x/component" 11 | materials "gioui.org/x/component" 12 | "git.sr.ht/~whereswaldon/sprig/core" 13 | "git.sr.ht/~whereswaldon/sprig/icons" 14 | sprigWidget "git.sr.ht/~whereswaldon/sprig/widget" 15 | sprigTheme "git.sr.ht/~whereswaldon/sprig/widget/theme" 16 | ) 17 | 18 | type SettingsView struct { 19 | manager ViewManager 20 | 21 | core.App 22 | 23 | widget.List 24 | ConnectionForm sprigWidget.TextForm 25 | IdentityButton widget.Clickable 26 | CommunityList layout.List 27 | CommunityBoxes []widget.Bool 28 | ProfilingSwitch widget.Bool 29 | ThemeingSwitch widget.Bool 30 | NotificationsSwitch widget.Bool 31 | TestNotificationsButton widget.Clickable 32 | TestResults string 33 | BottomBarSwitch widget.Bool 34 | DockNavSwitch widget.Bool 35 | DarkModeSwitch widget.Bool 36 | UseOrchardStoreSwitch widget.Bool 37 | } 38 | 39 | type Section struct { 40 | *material.Theme 41 | Heading string 42 | Items []layout.Widget 43 | } 44 | 45 | var sectionItemInset = layout.UniformInset(unit.Dp(8)) 46 | var itemInset = layout.Inset{ 47 | Left: unit.Dp(8), 48 | Right: unit.Dp(8), 49 | Top: unit.Dp(2), 50 | Bottom: unit.Dp(2), 51 | } 52 | 53 | func (s Section) Layout(gtx C) D { 54 | items := make([]layout.FlexChild, len(s.Items)+1) 55 | items[0] = layout.Rigid(component.SubheadingDivider(s.Theme, s.Heading).Layout) 56 | for i := range s.Items { 57 | items[i+1] = layout.Rigid(s.Items[i]) 58 | } 59 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, items...) 60 | } 61 | 62 | type SimpleSectionItem struct { 63 | *material.Theme 64 | Control layout.Widget 65 | Context string 66 | } 67 | 68 | func (s SimpleSectionItem) Layout(gtx C) D { 69 | return layout.Inset{ 70 | Top: unit.Dp(4), 71 | Bottom: unit.Dp(4), 72 | }.Layout(gtx, func(gtx C) D { 73 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 74 | layout.Rigid(func(gtx C) D { 75 | return s.Control(gtx) 76 | }), 77 | layout.Rigid(func(gtx C) D { 78 | if s.Context == "" { 79 | return D{} 80 | } 81 | return itemInset.Layout(gtx, material.Body2(s.Theme, s.Context).Layout) 82 | }), 83 | ) 84 | }) 85 | } 86 | 87 | var _ View = &SettingsView{} 88 | 89 | func NewCommunityMenuView(app core.App) View { 90 | c := &SettingsView{ 91 | App: app, 92 | } 93 | c.List.Axis = layout.Vertical 94 | c.ConnectionForm.TextField.SetText(c.Settings().Address()) 95 | c.ConnectionForm.TextField.SingleLine = true 96 | c.ConnectionForm.TextField.Submit = true 97 | return c 98 | } 99 | 100 | func (c *SettingsView) HandleIntent(intent Intent) {} 101 | 102 | func (c *SettingsView) AppBarData() (bool, string, []materials.AppBarAction, []materials.OverflowAction) { 103 | return true, "Settings", []materials.AppBarAction{}, []materials.OverflowAction{} 104 | } 105 | 106 | func (c *SettingsView) NavItem() *materials.NavItem { 107 | return &materials.NavItem{ 108 | Name: "Settings", 109 | Icon: icons.SettingsIcon, 110 | } 111 | } 112 | 113 | func (c *SettingsView) Update(gtx layout.Context) { 114 | settingsChanged := false 115 | for i := range c.CommunityBoxes { 116 | box := &c.CommunityBoxes[i] 117 | if box.Update(gtx) { 118 | log.Println("updated") 119 | } 120 | } 121 | if c.IdentityButton.Clicked(gtx) { 122 | c.manager.RequestViewSwitch(IdentityFormID) 123 | } 124 | if c.ProfilingSwitch.Update(gtx) { 125 | c.manager.SetProfiling(c.ProfilingSwitch.Value) 126 | } 127 | if c.ThemeingSwitch.Update(gtx) { 128 | c.manager.SetThemeing(c.ThemeingSwitch.Value) 129 | } 130 | if c.ConnectionForm.Submitted() { 131 | c.Settings().SetAddress(c.ConnectionForm.TextField.Text()) 132 | settingsChanged = true 133 | c.Sprout().ConnectTo(c.Settings().Address()) 134 | } 135 | if c.NotificationsSwitch.Update(gtx) { 136 | c.Settings().SetNotificationsGloballyAllowed(c.NotificationsSwitch.Value) 137 | settingsChanged = true 138 | } 139 | if c.TestNotificationsButton.Clicked(gtx) { 140 | err := c.Notifications().Notify("Testing!", "This is a test notification from sprig.") 141 | if err == nil { 142 | c.TestResults = "Sent without errors" 143 | } else { 144 | c.TestResults = "Failed: " + err.Error() 145 | } 146 | } 147 | if c.BottomBarSwitch.Update(gtx) { 148 | c.Settings().SetBottomAppBar(c.BottomBarSwitch.Value) 149 | settingsChanged = true 150 | } 151 | if c.DockNavSwitch.Update(gtx) { 152 | c.Settings().SetDockNavDrawer(c.DockNavSwitch.Value) 153 | settingsChanged = true 154 | } 155 | if c.DarkModeSwitch.Update(gtx) { 156 | c.Settings().SetDarkMode(c.DarkModeSwitch.Value) 157 | settingsChanged = true 158 | } 159 | if c.UseOrchardStoreSwitch.Update(gtx) { 160 | c.Settings().SetUseOrchardStore(c.UseOrchardStoreSwitch.Value) 161 | settingsChanged = true 162 | } 163 | if settingsChanged { 164 | c.manager.ApplySettings(c.Settings()) 165 | go c.Settings().Persist() 166 | } 167 | } 168 | 169 | func (c *SettingsView) BecomeVisible() { 170 | c.ConnectionForm.TextField.SetText(c.Settings().Address()) 171 | c.NotificationsSwitch.Value = c.Settings().NotificationsGloballyAllowed() 172 | c.BottomBarSwitch.Value = c.Settings().BottomAppBar() 173 | c.DockNavSwitch.Value = c.Settings().DockNavDrawer() 174 | c.DarkModeSwitch.Value = c.Settings().DarkMode() 175 | c.UseOrchardStoreSwitch.Value = c.Settings().UseOrchardStore() 176 | } 177 | 178 | func (c *SettingsView) Layout(gtx layout.Context) layout.Dimensions { 179 | sTheme := c.Theme().Current() 180 | theme := sTheme.Theme 181 | sections := []Section{ 182 | { 183 | Heading: "Identity", 184 | Items: []layout.Widget{ 185 | func(gtx C) D { 186 | if c.Settings().ActiveArborIdentityID() != nil { 187 | id, _ := c.Settings().Identity() 188 | return itemInset.Layout(gtx, sprigTheme.AuthorName(sTheme, string(id.Name.Blob), id.ID(), true).Layout) 189 | } 190 | return itemInset.Layout(gtx, material.Button(theme, &c.IdentityButton, "Create new Identity").Layout) 191 | }, 192 | }, 193 | }, 194 | { 195 | Heading: "Connection", 196 | Items: []layout.Widget{ 197 | SimpleSectionItem{ 198 | Theme: theme, 199 | Control: func(gtx C) D { 200 | return itemInset.Layout(gtx, func(gtx C) D { 201 | form := sprigTheme.TextForm(sTheme, &c.ConnectionForm, "Connect", "HOST:PORT") 202 | return form.Layout(gtx) 203 | }) 204 | }, 205 | Context: "You can restart your connection to a relay by hitting the Connect button above without changing the address.", 206 | }.Layout, 207 | }, 208 | }, 209 | { 210 | Heading: "Notifications", 211 | Items: []layout.Widget{ 212 | SimpleSectionItem{ 213 | Theme: theme, 214 | Control: func(gtx C) D { 215 | return layout.Flex{Alignment: layout.Middle}.Layout(gtx, 216 | layout.Rigid(func(gtx C) D { 217 | return itemInset.Layout(gtx, material.Switch(theme, &c.NotificationsSwitch, "Enable Notifications").Layout) 218 | }), 219 | layout.Rigid(func(gtx C) D { 220 | return itemInset.Layout(gtx, material.Body1(theme, "Enable notifications").Layout) 221 | }), 222 | layout.Rigid(func(gtx C) D { 223 | return itemInset.Layout(gtx, material.Button(theme, &c.TestNotificationsButton, "Test").Layout) 224 | }), 225 | layout.Rigid(func(gtx C) D { 226 | return itemInset.Layout(gtx, material.Body2(theme, c.TestResults).Layout) 227 | }), 228 | ) 229 | }, 230 | Context: "Currently supported on Android and Linux/BSD. macOS support coming soon.", 231 | }.Layout, 232 | }, 233 | }, 234 | { 235 | Heading: "Store", 236 | Items: []layout.Widget{ 237 | SimpleSectionItem{ 238 | Theme: theme, 239 | Control: func(gtx C) D { 240 | return layout.Flex{Alignment: layout.Middle}.Layout(gtx, 241 | layout.Rigid(func(gtx C) D { 242 | return itemInset.Layout(gtx, material.Switch(theme, &c.UseOrchardStoreSwitch, "Use Orchard Store").Layout) 243 | }), 244 | layout.Rigid(func(gtx C) D { 245 | return itemInset.Layout(gtx, material.Body1(theme, "Use Orchard store").Layout) 246 | }), 247 | ) 248 | }, 249 | Context: "Orchard is a single-file read-oriented database for storing nodes.", 250 | }.Layout, 251 | }, 252 | }, 253 | { 254 | Heading: "User Interface", 255 | Items: []layout.Widget{ 256 | SimpleSectionItem{ 257 | Theme: theme, 258 | Control: func(gtx C) D { 259 | return layout.Flex{Alignment: layout.Middle}.Layout(gtx, 260 | layout.Rigid(func(gtx C) D { 261 | return itemInset.Layout(gtx, material.Switch(theme, &c.BottomBarSwitch, "Use Bottom App Bar").Layout) 262 | }), 263 | layout.Rigid(func(gtx C) D { 264 | return itemInset.Layout(gtx, material.Body1(theme, "Use bottom app bar").Layout) 265 | }), 266 | ) 267 | }, 268 | Context: "Only recommended on mobile devices.", 269 | }.Layout, 270 | SimpleSectionItem{ 271 | Theme: theme, 272 | Control: func(gtx C) D { 273 | return layout.Flex{Alignment: layout.Middle}.Layout(gtx, 274 | layout.Rigid(func(gtx C) D { 275 | return itemInset.Layout(gtx, material.Switch(theme, &c.DockNavSwitch, "Dock navigation").Layout) 276 | }), 277 | layout.Rigid(func(gtx C) D { 278 | return itemInset.Layout(gtx, material.Body1(theme, "Dock navigation to the left edge of the UI").Layout) 279 | }), 280 | ) 281 | }, 282 | Context: "Only recommended on desktop devices.", 283 | }.Layout, 284 | SimpleSectionItem{ 285 | Theme: theme, 286 | Control: func(gtx C) D { 287 | return layout.Flex{Alignment: layout.Middle}.Layout(gtx, 288 | layout.Rigid(func(gtx C) D { 289 | return itemInset.Layout(gtx, material.Switch(theme, &c.DarkModeSwitch, "Dark Mode").Layout) 290 | }), 291 | layout.Rigid(func(gtx C) D { 292 | return itemInset.Layout(gtx, material.Body1(theme, "Dark Mode").Layout) 293 | }), 294 | ) 295 | }, 296 | }.Layout, 297 | }, 298 | }, 299 | { 300 | Heading: "Developer", 301 | Items: []layout.Widget{ 302 | SimpleSectionItem{ 303 | Theme: theme, 304 | Control: func(gtx C) D { 305 | return layout.Flex{Alignment: layout.Middle}.Layout(gtx, 306 | layout.Rigid(func(gtx C) D { 307 | return itemInset.Layout(gtx, material.Switch(theme, &c.ProfilingSwitch, "Enable Profiling").Layout) 308 | }), 309 | layout.Rigid(func(gtx C) D { 310 | return itemInset.Layout(gtx, material.Body1(theme, "Display graphics profiling").Layout) 311 | }), 312 | ) 313 | }, 314 | }.Layout, 315 | SimpleSectionItem{ 316 | Theme: theme, 317 | Control: func(gtx C) D { 318 | return layout.Flex{Alignment: layout.Middle}.Layout(gtx, 319 | layout.Rigid(func(gtx C) D { 320 | return itemInset.Layout(gtx, material.Switch(theme, &c.ThemeingSwitch, "Enable Theme Editor").Layout) 321 | }), 322 | layout.Rigid(func(gtx C) D { 323 | return itemInset.Layout(gtx, material.Body1(theme, "Display theme editor").Layout) 324 | }), 325 | ) 326 | }, 327 | }.Layout, 328 | func(gtx C) D { 329 | return itemInset.Layout(gtx, material.Body1(theme, VersionString).Layout) 330 | }, 331 | }, 332 | }, 333 | } 334 | return material.List(theme, &c.List).Layout(gtx, len(sections), func(gtx C, index int) D { 335 | return layout.UniformInset(unit.Dp(8)).Layout(gtx, func(gtx C) D { 336 | return component.Surface(theme).Layout(gtx, func(gtx C) D { 337 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 338 | return itemInset.Layout(gtx, func(gtx C) D { 339 | sections[index].Theme = theme 340 | return sections[index].Layout(gtx) 341 | }) 342 | }) 343 | }) 344 | }) 345 | } 346 | 347 | func (c *SettingsView) SetManager(mgr ViewManager) { 348 | c.manager = mgr 349 | } 350 | -------------------------------------------------------------------------------- /sprig.app.template/Contents/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | BuildMachineOSBuild 6 | 18G103 7 | CFBundleDevelopmentRegion 8 | en 9 | CFBundleExecutable 10 | sprig-mac 11 | CFBundleIdentifier 12 | chat.arbor.sprig 13 | CFBundleInfoDictionaryVersion 14 | 6.0 15 | CFBundleName 16 | sprig 17 | CFBundleIconFile 18 | sprig.icns 19 | CFBundlePackageType 20 | APPL 21 | CFBundleShortVersionString 22 | 1.0 23 | CFBundleSupportedPlatforms 24 | 25 | MacOSX 26 | 27 | CFBundleVersion 28 | 1 29 | DTCompiler 30 | com.apple.compilers.llvm.clang.1_0 31 | DTPlatformBuild 32 | 11C505 33 | DTPlatformVersion 34 | GM 35 | DTSDKBuild 36 | 19B90 37 | DTSDKName 38 | macosx10.15 39 | DTXcode 40 | 1130 41 | DTXcodeBuild 42 | 11C505 43 | LSMinimumSystemVersion 44 | 10.14 45 | NSPrincipalClass 46 | NSApplication 47 | NSSupportsAutomaticTermination 48 | 49 | NSSupportsSuddenTermination 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /sprig.app.template/Contents/PkgInfo: -------------------------------------------------------------------------------- 1 | APPL???? -------------------------------------------------------------------------------- /sprig.app.template/Contents/_CodeSignature/CodeResources: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | files 6 | 7 | files2 8 | 9 | rules 10 | 11 | ^Resources/ 12 | 13 | ^Resources/.*\.lproj/ 14 | 15 | optional 16 | 17 | weight 18 | 1000 19 | 20 | ^Resources/.*\.lproj/locversion.plist$ 21 | 22 | omit 23 | 24 | weight 25 | 1100 26 | 27 | ^Resources/Base\.lproj/ 28 | 29 | weight 30 | 1010 31 | 32 | ^version.plist$ 33 | 34 | 35 | rules2 36 | 37 | .*\.dSYM($|/) 38 | 39 | weight 40 | 11 41 | 42 | ^(.*/)?\.DS_Store$ 43 | 44 | omit 45 | 46 | weight 47 | 2000 48 | 49 | ^(Frameworks|SharedFrameworks|PlugIns|Plug-ins|XPCServices|Helpers|MacOS|Library/(Automator|Spotlight|LoginItems))/ 50 | 51 | nested 52 | 53 | weight 54 | 10 55 | 56 | ^.* 57 | 58 | ^Info\.plist$ 59 | 60 | omit 61 | 62 | weight 63 | 20 64 | 65 | ^PkgInfo$ 66 | 67 | omit 68 | 69 | weight 70 | 20 71 | 72 | ^Resources/ 73 | 74 | weight 75 | 20 76 | 77 | ^Resources/.*\.lproj/ 78 | 79 | optional 80 | 81 | weight 82 | 1000 83 | 84 | ^Resources/.*\.lproj/locversion.plist$ 85 | 86 | omit 87 | 88 | weight 89 | 1100 90 | 91 | ^Resources/Base\.lproj/ 92 | 93 | weight 94 | 1010 95 | 96 | ^[^/]+$ 97 | 98 | nested 99 | 100 | weight 101 | 10 102 | 103 | ^embedded\.provisionprofile$ 104 | 105 | weight 106 | 20 107 | 108 | ^version\.plist$ 109 | 110 | weight 111 | 20 112 | 113 | 114 | 115 | 116 | -------------------------------------------------------------------------------- /subscription-setup-form.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "time" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/unit" 8 | "gioui.org/widget" 9 | "gioui.org/widget/material" 10 | materials "gioui.org/x/component" 11 | "git.sr.ht/~whereswaldon/sprig/core" 12 | "git.sr.ht/~whereswaldon/sprig/icons" 13 | ) 14 | 15 | type SubSetupFormView struct { 16 | manager ViewManager 17 | 18 | core.App 19 | 20 | SubStateManager 21 | ConnectionList layout.List 22 | 23 | Refresh, Continue widget.Clickable 24 | } 25 | 26 | var _ View = &SubSetupFormView{} 27 | 28 | func NewSubSetupFormView(app core.App) View { 29 | c := &SubSetupFormView{ 30 | App: app, 31 | } 32 | c.SubStateManager = NewSubStateManager(app, func() { 33 | c.manager.RequestInvalidate() 34 | }) 35 | c.ConnectionList.Axis = layout.Vertical 36 | return c 37 | } 38 | 39 | func (c *SubSetupFormView) HandleIntent(intent Intent) {} 40 | 41 | func (c *SubSetupFormView) BecomeVisible() { 42 | c.SubStateManager.Refresh() 43 | go func() { 44 | time.Sleep(time.Second) 45 | c.SubStateManager.Refresh() 46 | }() 47 | } 48 | 49 | func (c *SubSetupFormView) NavItem() *materials.NavItem { 50 | return nil 51 | } 52 | 53 | func (c *SubSetupFormView) AppBarData() (bool, string, []materials.AppBarAction, []materials.OverflowAction) { 54 | return false, "", nil, nil 55 | } 56 | 57 | func (c *SubSetupFormView) Update(gtx layout.Context) { 58 | c.SubStateManager.Update(gtx) 59 | if c.Refresh.Clicked(gtx) { 60 | c.SubStateManager.Refresh() 61 | } 62 | if c.Continue.Clicked(gtx) { 63 | c.manager.SetView(ReplyViewID) 64 | } 65 | } 66 | 67 | func (c *SubSetupFormView) Layout(gtx layout.Context) layout.Dimensions { 68 | c.Update(gtx) 69 | sTheme := c.Theme().Current() 70 | theme := sTheme.Theme 71 | inset := layout.UniformInset(unit.Dp(12)) 72 | 73 | return layout.Flex{ 74 | Axis: layout.Vertical, 75 | Alignment: layout.Middle, 76 | }.Layout(gtx, 77 | layout.Rigid(func(gtx C) D { 78 | return inset.Layout(gtx, func(gtx C) D { 79 | return material.Body1(theme, "Subscribe to a few communities to get started:").Layout(gtx) 80 | }) 81 | }), 82 | layout.Flexed(1.0, func(gtx C) D { 83 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, SubscriptionList(theme, &c.ConnectionList, c.Subs).Layout) 84 | }), 85 | layout.Rigid(func(gtx C) D { 86 | return inset.Layout(gtx, func(gtx C) D { 87 | return layout.Flex{Spacing: layout.SpaceAround}.Layout(gtx, 88 | layout.Rigid(func(gtx C) D { 89 | return material.IconButton(theme, &c.Refresh, icons.RefreshIcon, "Refresh").Layout(gtx) 90 | }), 91 | layout.Rigid(func(gtx C) D { 92 | return material.IconButton(theme, &c.Continue, icons.ForwardIcon, "Continue").Layout(gtx) 93 | }), 94 | ) 95 | }) 96 | }), 97 | ) 98 | } 99 | 100 | func (c *SubSetupFormView) SetManager(mgr ViewManager) { 101 | c.manager = mgr 102 | } 103 | -------------------------------------------------------------------------------- /subscription-view.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "sort" 6 | "strings" 7 | "time" 8 | 9 | "gioui.org/layout" 10 | "gioui.org/unit" 11 | "gioui.org/widget" 12 | "gioui.org/widget/material" 13 | "gioui.org/x/component" 14 | materials "gioui.org/x/component" 15 | forest "git.sr.ht/~whereswaldon/forest-go" 16 | "git.sr.ht/~whereswaldon/forest-go/fields" 17 | "git.sr.ht/~whereswaldon/latest" 18 | "git.sr.ht/~whereswaldon/sprig/core" 19 | "git.sr.ht/~whereswaldon/sprig/icons" 20 | sprigTheme "git.sr.ht/~whereswaldon/sprig/widget/theme" 21 | ) 22 | 23 | // Sub describes the state of a subscription to a community across many 24 | // connected relays. 25 | type Sub struct { 26 | *forest.Community 27 | ActiveHostingRelays []string 28 | Subbed widget.Bool 29 | } 30 | 31 | type SubscriptionView struct { 32 | manager ViewManager 33 | 34 | core.App 35 | 36 | SubStateManager 37 | ConnectionList layout.List 38 | 39 | Refresh widget.Clickable 40 | } 41 | 42 | var _ View = &SubscriptionView{} 43 | 44 | func NewSubscriptionView(app core.App) View { 45 | c := &SubscriptionView{ 46 | App: app, 47 | } 48 | c.SubStateManager = NewSubStateManager(app, func() { 49 | c.manager.RequestInvalidate() 50 | }) 51 | c.ConnectionList.Axis = layout.Vertical 52 | return c 53 | } 54 | 55 | func (c *SubscriptionView) AppBarData() (bool, string, []materials.AppBarAction, []materials.OverflowAction) { 56 | return true, "Subscriptions", []materials.AppBarAction{ 57 | materials.SimpleIconAction(&c.Refresh, icons.RefreshIcon, materials.OverflowAction{ 58 | Name: "Refresh", 59 | Tag: &c.Refresh, 60 | }), 61 | }, []materials.OverflowAction{} 62 | } 63 | 64 | func (c *SubscriptionView) NavItem() *materials.NavItem { 65 | return &materials.NavItem{ 66 | Tag: c, 67 | Name: "Subscriptions", 68 | Icon: icons.SubscriptionIcon, 69 | } 70 | } 71 | 72 | func (c *SubscriptionView) Update(gtx layout.Context) { 73 | c.SubStateManager.Update(gtx) 74 | if c.Refresh.Clicked(gtx) { 75 | c.SubStateManager.Refresh() 76 | } 77 | } 78 | func (c *SubscriptionView) Layout(gtx layout.Context) layout.Dimensions { 79 | c.Update(gtx) 80 | sTheme := c.Theme().Current() 81 | theme := sTheme.Theme 82 | 83 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, SubscriptionList(theme, &c.ConnectionList, c.Subs).Layout) 84 | } 85 | 86 | func (c *SubscriptionView) SetManager(mgr ViewManager) { 87 | c.manager = mgr 88 | } 89 | 90 | func (c *SubscriptionView) HandleIntent(intent Intent) {} 91 | 92 | func (c *SubscriptionView) BecomeVisible() { 93 | c.SubStateManager.Refresh() 94 | } 95 | 96 | // SubscriptionCardStyle configures the presentation of a card with controls and 97 | // information about a subscription. 98 | type SubscriptionCardStyle struct { 99 | *material.Theme 100 | *Sub 101 | // Inset is applied to each element of the card and can be used to 102 | // control their minimum spacing relative to one another. 103 | layout.Inset 104 | } 105 | 106 | func SubscriptionCard(th *material.Theme, state *Sub) SubscriptionCardStyle { 107 | return SubscriptionCardStyle{ 108 | Sub: state, 109 | Inset: layout.UniformInset(unit.Dp(4)), 110 | Theme: th, 111 | } 112 | } 113 | 114 | func (s SubscriptionCardStyle) Layout(gtx C) D { 115 | return component.Surface(s.Theme).Layout(gtx, func(gtx C) D { 116 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 117 | return s.Inset.Layout(gtx, func(gtx C) D { 118 | return layout.Flex{ 119 | Spacing: layout.SpaceBetween, 120 | }.Layout(gtx, 121 | layout.Rigid(func(gtx C) D { 122 | return s.Inset.Layout(gtx, func(gtx C) D { 123 | return material.Switch(s.Theme, &s.Subbed, "Subscribed").Layout(gtx) 124 | }) 125 | }), 126 | layout.Rigid(func(gtx C) D { 127 | return s.Inset.Layout(gtx, func(gtx C) D { 128 | return sprigTheme.CommunityName(s.Theme, string(s.Community.Name.Blob), s.Community.ID()).Layout(gtx) 129 | }) 130 | }), 131 | layout.Rigid(func(gtx C) D { 132 | return s.Inset.Layout(gtx, func(gtx C) D { 133 | return material.Body2(s.Theme, strings.Join(s.ActiveHostingRelays, "\n")).Layout(gtx) 134 | }) 135 | }), 136 | ) 137 | }) 138 | }) 139 | } 140 | 141 | // SubscriptionListStyle lays out a scrollable list of subscription cards. 142 | type SubscriptionListStyle struct { 143 | *material.Theme 144 | layout.Inset 145 | ConnectionList *layout.List 146 | Subs []Sub 147 | } 148 | 149 | func SubscriptionList(th *material.Theme, list *layout.List, subs []Sub) SubscriptionListStyle { 150 | return SubscriptionListStyle{ 151 | Inset: layout.UniformInset(unit.Dp(4)), 152 | Theme: th, 153 | ConnectionList: list, 154 | Subs: subs, 155 | } 156 | } 157 | 158 | func (s SubscriptionListStyle) Layout(gtx layout.Context) layout.Dimensions { 159 | return s.ConnectionList.Layout(gtx, len(s.Subs), 160 | func(gtx C, index int) D { 161 | return s.Inset.Layout(gtx, func(gtx C) D { 162 | return SubscriptionCard(s.Theme, &s.Subs[index]).Layout(gtx) 163 | }) 164 | }) 165 | } 166 | 167 | // SubStateManager supervises and updates the list of subscribed communities 168 | type SubStateManager struct { 169 | core.App 170 | invalidate func() 171 | latest.Worker 172 | Subs []Sub 173 | } 174 | 175 | // NewSubStateManager creates a new manager. The invalidate function is provided 176 | // to it as a way to signal when the UI should be updated as a result of it 177 | // finishing work. 178 | func NewSubStateManager(app core.App, invalidate func()) SubStateManager { 179 | s := SubStateManager{App: app, invalidate: invalidate} 180 | s.Worker = latest.NewWorker(func(in interface{}) interface{} { 181 | return s.reconcileSubscriptions(in.([]Sub)) 182 | }) 183 | return s 184 | } 185 | 186 | // Update checks whether the backend has new results from the background worker 187 | // goroutine and updates internal state to reflect those results. It should 188 | // always be invoked before using the Subs field directly. 189 | func (c *SubStateManager) Update(gtx C) { 190 | outer: 191 | for { 192 | select { 193 | case newSubs := <-c.Worker.Raw(): 194 | c.Subs = newSubs.([]Sub) 195 | log.Println("updated subs", c.Subs) 196 | default: 197 | break outer 198 | } 199 | } 200 | var changes []Sub 201 | for i := range c.Subs { 202 | sub := &c.Subs[i] 203 | if sub.Subbed.Update(gtx) { 204 | changes = append(changes, *sub) 205 | } 206 | } 207 | if len(changes) > 0 { 208 | c.Worker.Push(changes) 209 | } 210 | } 211 | 212 | // Refresh requests the background goroutine to initiate an update of the Subs 213 | // field. 214 | func (c *SubStateManager) Refresh() { 215 | c.Worker.Push([]Sub(nil)) 216 | } 217 | 218 | func (c *SubStateManager) reconcileSubscriptions(changes []Sub) []Sub { 219 | for _, sub := range changes { 220 | for _, addr := range sub.ActiveHostingRelays { 221 | timeout := time.NewTicker(time.Second * 5) 222 | worker := c.Sprout().WorkerFor(addr) 223 | var subFunc func(*forest.Community, <-chan time.Time) error 224 | var sessionFunc func(*fields.QualifiedHash) 225 | if !sub.Subbed.Value { 226 | subFunc = worker.SendUnsubscribe 227 | sessionFunc = worker.Unsubscribe 228 | c.Settings().RemoveSubscription(sub.Community.ID().String()) 229 | } else { 230 | subFunc = worker.SendSubscribe 231 | sessionFunc = worker.Subscribe 232 | c.Settings().AddSubscription(sub.Community.ID().String()) 233 | go core.BootstrapSubscribed(worker, []string{sub.Community.ID().String()}) 234 | } 235 | if err := subFunc(sub.Community, timeout.C); err != nil { 236 | log.Printf("Failed changing sub for %s to %v on relay %s", sub.ID(), sub.Subbed.Value, addr) 237 | } else { 238 | sessionFunc(sub.Community.ID()) 239 | log.Printf("Changed subscription for %s to %v on relay %s", sub.ID(), sub.Subbed.Value, addr) 240 | } 241 | go c.Settings().Persist() 242 | } 243 | } 244 | subs := c.refreshSubs() 245 | c.invalidate() 246 | return subs 247 | } 248 | 249 | func (c *SubStateManager) refreshSubs() []Sub { 250 | var out []Sub 251 | communities := map[string]Sub{} 252 | for _, conn := range c.Sprout().Connections() { 253 | func() { 254 | worker := c.Sprout().WorkerFor(conn) 255 | worker.Session.RLock() 256 | defer worker.Session.RUnlock() 257 | response, err := worker.SendList(fields.NodeTypeCommunity, 1024, time.NewTicker(time.Second*5).C) 258 | if err != nil { 259 | log.Printf("Failed listing communities on worker %s: %v", conn, err) 260 | } else { 261 | for _, n := range response.Nodes { 262 | n, isCommunity := n.(*forest.Community) 263 | if !isCommunity { 264 | continue 265 | } 266 | id := n.ID().String() 267 | existing, ok := communities[id] 268 | if !ok { 269 | existing = Sub{ 270 | Community: n, 271 | } 272 | } 273 | existing.ActiveHostingRelays = append(existing.ActiveHostingRelays, conn) 274 | communities[id] = existing 275 | 276 | } 277 | } 278 | for id := range worker.Session.Communities { 279 | data := communities[id.String()] 280 | data.Subbed.Value = true 281 | communities[id.String()] = data 282 | } 283 | }() 284 | } 285 | for _, sub := range c.Settings().Subscriptions() { 286 | if _, alreadyInList := communities[sub]; alreadyInList { 287 | continue 288 | } 289 | var hash fields.QualifiedHash 290 | hash.UnmarshalText([]byte(sub)) 291 | communityNode, has, err := c.Arbor().Store().GetCommunity(&hash) 292 | if err != nil { 293 | log.Printf("Settings indicate a subscription to %v, but loading it from local store failed: %v", sub, err) 294 | continue 295 | } else if !has { 296 | log.Printf("Settings indicate a subscription to %v, but it is not present in the local store.", sub) 297 | continue 298 | } 299 | community, ok := communityNode.(*forest.Community) 300 | if !ok { 301 | log.Printf("Settings indicate a subscription to %v, but it is not a community.", sub) 302 | continue 303 | } 304 | communities[sub] = Sub{ 305 | Community: community, 306 | Subbed: widget.Bool{Value: true}, 307 | ActiveHostingRelays: []string{"no known hosting relays"}, 308 | } 309 | } 310 | for _, sub := range communities { 311 | out = append(out, sub) 312 | } 313 | sort.Slice(out, func(i, j int) bool { 314 | iID := out[i].Community.ID().String() 315 | jID := out[j].Community.ID().String() 316 | return strings.Compare(iID, jID) < 0 317 | }) 318 | return out 319 | } 320 | -------------------------------------------------------------------------------- /theme-editor.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "image/color" 5 | "log" 6 | 7 | "gioui.org/f32" 8 | "gioui.org/font/gofont" 9 | "gioui.org/layout" 10 | "gioui.org/op" 11 | "gioui.org/text" 12 | "gioui.org/unit" 13 | "gioui.org/widget/material" 14 | "git.sr.ht/~whereswaldon/sprig/core" 15 | "git.sr.ht/~whereswaldon/sprig/icons" 16 | sprigTheme "git.sr.ht/~whereswaldon/sprig/widget/theme" 17 | 18 | "gioui.org/x/colorpicker" 19 | materials "gioui.org/x/component" 20 | ) 21 | 22 | type ThemeEditorView struct { 23 | manager ViewManager 24 | core.App 25 | 26 | PrimaryDefault colorpicker.State 27 | PrimaryDark colorpicker.State 28 | PrimaryLight colorpicker.State 29 | 30 | SecondaryDefault colorpicker.State 31 | SecondaryDark colorpicker.State 32 | SecondaryLight colorpicker.State 33 | 34 | BackgroundDefault colorpicker.State 35 | BackgroundDark colorpicker.State 36 | BackgroundLight colorpicker.State 37 | 38 | TextColor colorpicker.State 39 | HintColor colorpicker.State 40 | InvertedTextColor colorpicker.State 41 | 42 | ColorsList layout.List 43 | listElems []colorListElement 44 | 45 | AncestorMux colorpicker.MuxState 46 | DescendantMux colorpicker.MuxState 47 | SelectedMux colorpicker.MuxState 48 | SiblingMux colorpicker.MuxState 49 | NonselectedMux colorpicker.MuxState 50 | 51 | MuxList layout.List 52 | muxListElems []muxListElement 53 | 54 | *sprigTheme.Theme 55 | widgetTheme *material.Theme 56 | } 57 | 58 | type colorListElement struct { 59 | *colorpicker.State 60 | Label string 61 | TargetColors []*color.NRGBA 62 | } 63 | 64 | type muxListElement struct { 65 | *colorpicker.MuxState 66 | Label string 67 | TargetColor **color.NRGBA 68 | } 69 | 70 | var _ View = &ThemeEditorView{} 71 | 72 | func NewThemeEditorView(app core.App) View { 73 | th := material.NewTheme() 74 | th.Shaper = text.NewShaper(text.WithCollection(gofont.Collection())) 75 | c := &ThemeEditorView{ 76 | App: app, 77 | widgetTheme: th, 78 | } 79 | 80 | c.ConfigurePickersFor(app.Theme().Current()) 81 | return c 82 | } 83 | 84 | func (c *ThemeEditorView) ConfigurePickersFor(th *sprigTheme.Theme) { 85 | c.PrimaryDefault.SetColor(th.Primary.Default.Bg) 86 | c.PrimaryDark.SetColor(th.Primary.Dark.Bg) 87 | c.PrimaryLight.SetColor(th.Primary.Light.Bg) 88 | c.SecondaryDefault.SetColor(th.Secondary.Default.Bg) 89 | c.SecondaryDark.SetColor(th.Secondary.Dark.Bg) 90 | c.SecondaryLight.SetColor(th.Secondary.Light.Bg) 91 | c.BackgroundDefault.SetColor(th.Background.Default.Bg) 92 | c.BackgroundDark.SetColor(th.Background.Dark.Bg) 93 | c.BackgroundLight.SetColor(th.Background.Light.Bg) 94 | 95 | c.ColorsList.Axis = layout.Vertical 96 | c.listElems = []colorListElement{ 97 | { 98 | Label: "Primary", 99 | TargetColors: []*color.NRGBA{ 100 | &th.Primary.Default.Bg, 101 | &th.Theme.Palette.Bg, 102 | }, 103 | State: &c.PrimaryDefault, 104 | }, 105 | { 106 | Label: "Primary Light", 107 | TargetColors: []*color.NRGBA{ 108 | &th.Primary.Light.Bg, 109 | }, 110 | State: &c.PrimaryLight, 111 | }, 112 | { 113 | Label: "Primary Dark", 114 | TargetColors: []*color.NRGBA{ 115 | &th.Primary.Dark.Bg, 116 | }, 117 | State: &c.PrimaryDark, 118 | }, 119 | { 120 | Label: "Secondary", 121 | TargetColors: []*color.NRGBA{ 122 | &th.Secondary.Default.Bg, 123 | }, 124 | State: &c.SecondaryDefault, 125 | }, 126 | { 127 | Label: "Secondary Light", 128 | TargetColors: []*color.NRGBA{ 129 | &th.Secondary.Light.Bg, 130 | }, 131 | State: &c.SecondaryLight, 132 | }, 133 | { 134 | Label: "Secondary Dark", 135 | TargetColors: []*color.NRGBA{ 136 | &th.Secondary.Dark.Bg, 137 | }, 138 | State: &c.SecondaryDark, 139 | }, 140 | { 141 | Label: "Background", 142 | TargetColors: []*color.NRGBA{ 143 | &th.Background.Default.Bg, 144 | }, 145 | State: &c.BackgroundDefault, 146 | }, 147 | { 148 | Label: "Background Light", 149 | TargetColors: []*color.NRGBA{ 150 | &th.Background.Light.Bg, 151 | }, 152 | State: &c.BackgroundLight, 153 | }, 154 | { 155 | Label: "Background Dark", 156 | TargetColors: []*color.NRGBA{ 157 | &th.Background.Dark.Bg, 158 | }, 159 | State: &c.BackgroundDark, 160 | }, 161 | } 162 | 163 | muxOptions := []colorpicker.MuxOption{} 164 | for _, elem := range c.listElems { 165 | if len(elem.TargetColors) < 1 || elem.TargetColors[0] == nil { 166 | continue 167 | } 168 | elem.SetColor(*elem.TargetColors[0]) 169 | muxOptions = append(muxOptions, colorpicker.MuxOption{ 170 | Label: elem.Label, 171 | Value: elem.TargetColors[0], 172 | }) 173 | } 174 | c.muxListElems = []muxListElement{ 175 | { 176 | Label: "Ancestors", 177 | MuxState: &c.AncestorMux, 178 | TargetColor: &th.Ancestors, 179 | }, 180 | { 181 | Label: "Descendants", 182 | MuxState: &c.DescendantMux, 183 | TargetColor: &th.Descendants, 184 | }, 185 | { 186 | Label: "Selected", 187 | MuxState: &c.SelectedMux, 188 | TargetColor: &th.Selected, 189 | }, 190 | { 191 | Label: "Siblings", 192 | MuxState: &c.SiblingMux, 193 | TargetColor: &th.Siblings, 194 | }, 195 | { 196 | Label: "Unselected", 197 | MuxState: &c.NonselectedMux, 198 | TargetColor: &th.Unselected, 199 | }, 200 | } 201 | for _, mux := range c.muxListElems { 202 | *mux.MuxState = colorpicker.NewMuxState(muxOptions...) 203 | } 204 | } 205 | 206 | func (c *ThemeEditorView) BecomeVisible() { 207 | c.ConfigurePickersFor(c.App.Theme().Current()) 208 | } 209 | 210 | func (c *ThemeEditorView) HandleIntent(intent Intent) {} 211 | 212 | func (c *ThemeEditorView) NavItem() *materials.NavItem { 213 | return &materials.NavItem{ 214 | Name: "Theme", 215 | Icon: icons.CancelReplyIcon, 216 | } 217 | } 218 | 219 | func (c *ThemeEditorView) AppBarData() (bool, string, []materials.AppBarAction, []materials.OverflowAction) { 220 | return true, "Theme", []materials.AppBarAction{}, []materials.OverflowAction{} 221 | } 222 | 223 | func (c *ThemeEditorView) HandleClipboard(contents string) { 224 | } 225 | 226 | func (c *ThemeEditorView) Update(gtx layout.Context) { 227 | for i, elem := range c.listElems { 228 | if elem.Changed() { 229 | for _, target := range elem.TargetColors { 230 | *target = elem.Color() 231 | } 232 | op.InvalidateOp{}.Add(gtx.Ops) 233 | log.Printf("picker %d changed", i) 234 | } 235 | } 236 | for _, elem := range c.muxListElems { 237 | if elem.Update(gtx) { 238 | *elem.TargetColor = elem.Color() 239 | op.InvalidateOp{}.Add(gtx.Ops) 240 | log.Printf("mux changed") 241 | } 242 | } 243 | } 244 | 245 | func (c *ThemeEditorView) Layout(gtx layout.Context) layout.Dimensions { 246 | return c.layoutPickers(gtx) 247 | } 248 | 249 | func (c *ThemeEditorView) layoutPickers(gtx layout.Context) layout.Dimensions { 250 | return c.ColorsList.Layout(gtx, len(c.listElems)+1, func(gtx C, index int) D { 251 | if index == len(c.listElems) { 252 | return c.layoutMuxes(gtx) 253 | } 254 | return layout.Stack{}.Layout(gtx, 255 | layout.Expanded(func(gtx C) D { 256 | return sprigTheme.Rect{ 257 | Color: color.NRGBA{A: 255}, 258 | Size: f32.Point{ 259 | X: float32(gtx.Constraints.Min.X), 260 | Y: float32(gtx.Constraints.Min.Y), 261 | }, 262 | }.Layout(gtx) 263 | }), 264 | layout.Stacked(func(gtx C) D { 265 | return layout.UniformInset(unit.Dp(3)).Layout(gtx, func(gtx C) D { 266 | return layout.Stack{}.Layout(gtx, 267 | layout.Expanded(func(gtx C) D { 268 | return sprigTheme.Rect{ 269 | Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, 270 | Size: f32.Point{ 271 | X: float32(gtx.Constraints.Min.X), 272 | Y: float32(gtx.Constraints.Min.Y), 273 | }, 274 | }.Layout(gtx) 275 | }), 276 | layout.Stacked(func(gtx C) D { 277 | elem := c.listElems[index] 278 | dims := colorpicker.Picker(c.widgetTheme, elem.State, elem.Label).Layout(gtx) 279 | return dims 280 | }), 281 | ) 282 | }) 283 | }), 284 | ) 285 | }) 286 | } 287 | 288 | func (c *ThemeEditorView) layoutMuxes(gtx layout.Context) layout.Dimensions { 289 | return layout.Stack{}.Layout(gtx, 290 | layout.Expanded(func(gtx C) D { 291 | return sprigTheme.Rect{ 292 | Color: color.NRGBA{R: 255, G: 255, B: 255, A: 255}, 293 | Size: f32.Point{ 294 | X: float32(gtx.Constraints.Min.X), 295 | Y: float32(gtx.Constraints.Min.Y), 296 | }, 297 | }.Layout(gtx) 298 | }), 299 | layout.Stacked(func(gtx C) D { 300 | return c.MuxList.Layout(gtx, len(c.muxListElems), func(gtx C, index int) D { 301 | element := c.muxListElems[index] 302 | return colorpicker.Mux(c.widgetTheme, element.MuxState, element.Label).Layout(gtx) 303 | }) 304 | }), 305 | ) 306 | } 307 | 308 | func (c *ThemeEditorView) SetManager(mgr ViewManager) { 309 | c.manager = mgr 310 | } 311 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //+build tools 2 | 3 | package main 4 | 5 | import _ "gioui.org/cmd/gogio" 6 | 7 | /* 8 | This file locks gogio as a dependency so that its version will 9 | stay in sync with the version of gio that we use in our go.mod. 10 | */ 11 | -------------------------------------------------------------------------------- /version.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | var ( 4 | Version = "git" 5 | VersionString = "Build: " + Version 6 | ) 7 | -------------------------------------------------------------------------------- /view-manager.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "image" 6 | "runtime" 7 | "time" 8 | 9 | "gioui.org/app" 10 | "gioui.org/f32" 11 | "gioui.org/io/profile" 12 | "gioui.org/layout" 13 | "gioui.org/op/clip" 14 | "gioui.org/op/paint" 15 | "gioui.org/unit" 16 | "gioui.org/widget/material" 17 | materials "gioui.org/x/component" 18 | 19 | "git.sr.ht/~whereswaldon/sprig/core" 20 | "git.sr.ht/~whereswaldon/sprig/icons" 21 | sprigTheme "git.sr.ht/~whereswaldon/sprig/widget/theme" 22 | ) 23 | 24 | type ViewManager interface { 25 | // request that the primary view be switched to the view with the given ID 26 | RequestViewSwitch(ViewID) 27 | // set the primary view to be the view with the given ID. This does not 28 | // preserve the history of the previous view, so back navigation will not 29 | // work. 30 | SetView(ViewID) 31 | // associate a view with an ID 32 | RegisterView(id ViewID, view View) 33 | // register that a given view handles a given kind of intent 34 | RegisterIntentHandler(id ViewID, intent IntentID) 35 | // finds a view that can handle the intent and Pushes that view 36 | ExecuteIntent(intent Intent) bool 37 | // request a screen invalidation from outside of a render context 38 | RequestInvalidate() 39 | // handle logical "back" navigation operations 40 | HandleBackNavigation() 41 | // trigger a contextual app menu with the given title and actions 42 | RequestContextualBar(gtx layout.Context, title string, actions []materials.AppBarAction, overflow []materials.OverflowAction) 43 | // request that any contextual menu disappear 44 | DismissContextualBar(gtx layout.Context) 45 | // request that an app bar overflow menu disappear 46 | DismissOverflow(gtx layout.Context) 47 | // get the tag of a selected overflow message 48 | SelectedOverflowTag() interface{} 49 | // render the interface 50 | Layout(gtx layout.Context) layout.Dimensions 51 | // enable graphics profiling 52 | SetProfiling(bool) 53 | // enable live theme editing 54 | SetThemeing(bool) 55 | // apply settings changes relevant to the UI 56 | ApplySettings(core.SettingsService) 57 | } 58 | 59 | type viewManager struct { 60 | views map[ViewID]View 61 | current ViewID 62 | window *app.Window 63 | 64 | core.App 65 | 66 | *materials.ModalLayer 67 | materials.NavDrawer 68 | navAnim materials.VisibilityAnimation 69 | *materials.ModalNavDrawer 70 | *materials.AppBar 71 | 72 | intentToView map[IntentID]ViewID 73 | 74 | // track the tag of the overflow action selected within the last frame 75 | selectedOverflowTag interface{} 76 | 77 | // tracking the handling of "back" events 78 | viewStack []ViewID 79 | 80 | // dock the navigation drawer? 81 | dockDrawer bool 82 | 83 | // runtime profiling data 84 | profiling bool 85 | profile profile.Event 86 | lastMallocs uint64 87 | 88 | // runtime themeing state 89 | themeing bool 90 | themeView View 91 | } 92 | 93 | func NewViewManager(window *app.Window, app core.App) ViewManager { 94 | modal := materials.NewModal() 95 | drawer := materials.NewNav("Sprig", "Arbor chat client") 96 | vm := &viewManager{ 97 | App: app, 98 | views: make(map[ViewID]View), 99 | window: window, 100 | themeView: NewThemeEditorView(app), 101 | ModalLayer: modal, 102 | NavDrawer: drawer, 103 | intentToView: make(map[IntentID]ViewID), 104 | navAnim: materials.VisibilityAnimation{ 105 | Duration: time.Millisecond * 250, 106 | State: materials.Invisible, 107 | }, 108 | AppBar: materials.NewAppBar(modal), 109 | } 110 | vm.ModalNavDrawer = materials.ModalNavFrom(&vm.NavDrawer, vm.ModalLayer) 111 | vm.AppBar.NavigationIcon = icons.MenuIcon 112 | return vm 113 | } 114 | 115 | func (vm *viewManager) RequestInvalidate() { 116 | vm.window.Invalidate() 117 | } 118 | 119 | func (vm *viewManager) ApplySettings(settings core.SettingsService) { 120 | anchor := materials.Top 121 | if settings.BottomAppBar() { 122 | anchor = materials.Bottom 123 | } 124 | vm.AppBar.Anchor = anchor 125 | vm.ModalNavDrawer.Anchor = anchor 126 | vm.dockDrawer = settings.DockNavDrawer() 127 | vm.App.Theme().SetDarkMode(settings.DarkMode()) 128 | 129 | vm.ModalNavDrawer = materials.ModalNavFrom(&vm.NavDrawer, vm.ModalLayer) 130 | vm.themeView.BecomeVisible() 131 | 132 | if settings.DarkMode() { 133 | vm.NavDrawer.AlphaPalette = materials.AlphaPalette{ 134 | Hover: 100, 135 | Selected: 150, 136 | } 137 | } else { 138 | vm.NavDrawer.AlphaPalette = materials.AlphaPalette{ 139 | Hover: 25, 140 | Selected: 50, 141 | } 142 | } 143 | } 144 | 145 | func (vm *viewManager) RegisterView(id ViewID, view View) { 146 | if navItem := view.NavItem(); navItem != nil { 147 | vm.ModalNavDrawer.AddNavItem(materials.NavItem{ 148 | Tag: id, 149 | Name: navItem.Name, 150 | Icon: navItem.Icon, 151 | }) 152 | } 153 | vm.views[id] = view 154 | view.SetManager(vm) 155 | } 156 | 157 | func (vm *viewManager) RegisterIntentHandler(id ViewID, intentID IntentID) { 158 | vm.intentToView[intentID] = id 159 | } 160 | 161 | func (vm *viewManager) ExecuteIntent(intent Intent) bool { 162 | view, ok := vm.intentToView[intent.ID] 163 | if !ok { 164 | return false 165 | } 166 | vm.Push(view) 167 | vm.views[view].HandleIntent(intent) 168 | return true 169 | } 170 | 171 | func (vm *viewManager) SetView(id ViewID) { 172 | vm.current = id 173 | // vm.ModalNavDrawer.SetNavDestination(id) 174 | view := vm.views[vm.current] 175 | if showBar, title, actions, overflow := view.AppBarData(); showBar { 176 | vm.AppBar.Title = title 177 | vm.AppBar.SetActions(actions, overflow) 178 | } 179 | vm.NavDrawer.SetNavDestination(id) 180 | view.BecomeVisible() 181 | } 182 | 183 | func (vm *viewManager) RequestViewSwitch(id ViewID) { 184 | vm.Push(vm.current) 185 | vm.SetView(id) 186 | } 187 | 188 | func (vm *viewManager) RequestContextualBar(gtx layout.Context, title string, actions []materials.AppBarAction, overflow []materials.OverflowAction) { 189 | vm.AppBar.SetContextualActions(actions, overflow) 190 | vm.AppBar.StartContextual(gtx.Now, title) 191 | } 192 | 193 | func (vm *viewManager) DismissContextualBar(gtx layout.Context) { 194 | vm.AppBar.StopContextual(gtx.Now) 195 | } 196 | 197 | func (vm *viewManager) DismissOverflow(gtx layout.Context) { 198 | vm.AppBar.CloseOverflowMenu(gtx.Now) 199 | } 200 | 201 | func (vm *viewManager) SelectedOverflowTag() interface{} { 202 | return vm.selectedOverflowTag 203 | } 204 | 205 | func (vm *viewManager) HandleBackNavigation() { 206 | if len(vm.viewStack) > 0 { 207 | vm.Pop() 208 | } 209 | } 210 | 211 | func (vm *viewManager) Push(id ViewID) { 212 | vm.viewStack = append(vm.viewStack, id) 213 | } 214 | 215 | func (vm *viewManager) Pop() { 216 | finalIndex := len(vm.viewStack) - 1 217 | vm.current, vm.viewStack = vm.viewStack[finalIndex], vm.viewStack[:finalIndex] 218 | vm.ModalNavDrawer.SetNavDestination(vm.current) 219 | vm.window.Invalidate() 220 | } 221 | 222 | func (vm *viewManager) Layout(gtx layout.Context) layout.Dimensions { 223 | vm.selectedOverflowTag = nil 224 | for _, event := range vm.AppBar.Events(gtx) { 225 | switch event := event.(type) { 226 | case materials.AppBarNavigationClicked: 227 | if vm.dockDrawer { 228 | vm.navAnim.ToggleVisibility(gtx.Now) 229 | } else { 230 | vm.navAnim.Disappear(gtx.Now) 231 | vm.ModalNavDrawer.ToggleVisibility(gtx.Now) 232 | } 233 | case materials.AppBarOverflowActionClicked: 234 | vm.selectedOverflowTag = event.Tag 235 | } 236 | } 237 | if vm.ModalNavDrawer.NavDestinationChanged() { 238 | vm.RequestViewSwitch(vm.ModalNavDrawer.CurrentNavDestination().(ViewID)) 239 | } 240 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 241 | layout.Rigid(func(gtx C) D { 242 | return vm.layoutProfileTimings(gtx) 243 | }), 244 | layout.Rigid(func(gtx C) D { 245 | if !vm.themeing { 246 | gtx.Constraints.Min = gtx.Constraints.Max 247 | return vm.layoutCurrentView(gtx) 248 | } 249 | return layout.Flex{}.Layout(gtx, 250 | layout.Rigid(func(gtx C) D { 251 | gtx.Constraints.Max.X /= 2 252 | gtx.Constraints.Min = gtx.Constraints.Max 253 | return vm.layoutCurrentView(gtx) 254 | }), 255 | layout.Rigid(func(gtx C) D { 256 | return vm.layoutThemeing(gtx) 257 | }), 258 | ) 259 | }), 260 | ) 261 | } 262 | 263 | func (vm *viewManager) layoutCurrentView(gtx layout.Context) layout.Dimensions { 264 | view := vm.views[vm.current] 265 | view.Update(gtx) 266 | displayBar, _, _, _ := view.AppBarData() 267 | th := vm.App.Theme().Current() 268 | banner := func(gtx C) D { 269 | switch bannerConfig := vm.App.Banner().Top().(type) { 270 | case *core.LoadingBanner: 271 | secondary := th.Secondary.Default 272 | th := *(th.Theme) 273 | th.ContrastFg = th.Fg 274 | th.ContrastBg = th.Bg 275 | th.Palette = sprigTheme.ApplyAsNormal(th.Palette, secondary) 276 | return layout.Stack{}.Layout(gtx, 277 | layout.Expanded(func(gtx C) D { 278 | paint.FillShape(gtx.Ops, th.Bg, clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Op()) 279 | return D{Size: gtx.Constraints.Min} 280 | }), 281 | layout.Stacked(func(gtx C) D { 282 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, func(gtx C) D { 283 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 284 | return layout.Flex{Spacing: layout.SpaceAround}.Layout(gtx, 285 | layout.Rigid(material.Body1(&th, bannerConfig.Text).Layout), 286 | layout.Rigid(material.Loader(&th).Layout), 287 | ) 288 | }) 289 | }), 290 | ) 291 | default: 292 | return D{} 293 | } 294 | } 295 | 296 | bar := layout.Rigid(func(gtx C) D { 297 | if displayBar { 298 | return vm.AppBar.Layout(gtx, th.Theme, "Navigation", "More") 299 | } 300 | return layout.Dimensions{} 301 | }) 302 | content := layout.Flexed(1, func(gtx C) D { 303 | return layout.Flex{}.Layout(gtx, 304 | layout.Rigid(func(gtx C) D { 305 | gtx.Constraints.Max.X /= 3 306 | return vm.NavDrawer.Layout(gtx, th.Theme, &vm.navAnim) 307 | }), 308 | layout.Flexed(1, func(gtx C) D { 309 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 310 | layout.Rigid(banner), 311 | layout.Flexed(1.0, view.Layout), 312 | ) 313 | }), 314 | ) 315 | }) 316 | flex := layout.Flex{ 317 | Axis: layout.Vertical, 318 | } 319 | var dimensions layout.Dimensions 320 | if vm.AppBar.Anchor == materials.Top { 321 | dimensions = flex.Layout(gtx, 322 | bar, 323 | content, 324 | ) 325 | } else { 326 | dimensions = flex.Layout(gtx, 327 | content, 328 | bar, 329 | ) 330 | } 331 | vm.ModalLayer.Layout(gtx, th.Theme) 332 | return dimensions 333 | } 334 | 335 | func (vm *viewManager) layoutProfileTimings(gtx layout.Context) layout.Dimensions { 336 | if !vm.profiling { 337 | return D{} 338 | } 339 | for _, e := range gtx.Events(vm) { 340 | if e, ok := e.(profile.Event); ok { 341 | vm.profile = e 342 | } 343 | } 344 | profile.Op{Tag: vm}.Add(gtx.Ops) 345 | var mstats runtime.MemStats 346 | runtime.ReadMemStats(&mstats) 347 | mallocs := mstats.Mallocs - vm.lastMallocs 348 | vm.lastMallocs = mstats.Mallocs 349 | text := fmt.Sprintf("m: %d %s", mallocs, vm.profile.Timings) 350 | return layout.Stack{}.Layout(gtx, 351 | layout.Expanded(func(gtx C) D { 352 | return sprigTheme.Rect{ 353 | Color: vm.App.Theme().Current().Background.Light.Bg, 354 | Size: f32.Point{ 355 | X: float32(gtx.Constraints.Min.X), 356 | Y: float32(gtx.Constraints.Min.Y), 357 | }, 358 | }.Layout(gtx) 359 | }), 360 | layout.Stacked(func(gtx C) D { 361 | return layout.Inset{Top: unit.Dp(4), Left: unit.Dp(4)}.Layout(gtx, func(gtx C) D { 362 | label := material.Body1(vm.App.Theme().Current().Theme, text) 363 | label.Font.Typeface = "Go Mono" 364 | return label.Layout(gtx) 365 | }) 366 | }), 367 | ) 368 | } 369 | 370 | func (vm *viewManager) SetProfiling(isProfiling bool) { 371 | vm.profiling = isProfiling 372 | } 373 | 374 | func (vm *viewManager) SetThemeing(isThemeing bool) { 375 | vm.themeing = isThemeing 376 | } 377 | 378 | func (vm *viewManager) layoutThemeing(gtx C) D { 379 | vm.themeView.Update(gtx) 380 | return vm.themeView.Layout(gtx) 381 | } 382 | -------------------------------------------------------------------------------- /view.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "gioui.org/layout" 5 | materials "gioui.org/x/component" 6 | ) 7 | 8 | type View interface { 9 | SetManager(ViewManager) 10 | AppBarData() (bool, string, []materials.AppBarAction, []materials.OverflowAction) 11 | NavItem() *materials.NavItem 12 | BecomeVisible() 13 | HandleIntent(Intent) 14 | Update(gtx layout.Context) 15 | Layout(gtx layout.Context) layout.Dimensions 16 | } 17 | -------------------------------------------------------------------------------- /widget/composer.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "gioui.org/io/clipboard" 5 | "gioui.org/layout" 6 | "gioui.org/widget" 7 | "gioui.org/x/richtext" 8 | 9 | "git.sr.ht/~whereswaldon/forest-go/fields" 10 | "git.sr.ht/~whereswaldon/sprig/ds" 11 | "git.sr.ht/~whereswaldon/sprig/platform" 12 | ) 13 | 14 | // ComposerEvent represents a change in the Composer's state 15 | type ComposerEvent uint 16 | 17 | type MessageType int32 18 | 19 | const ( 20 | MessageTypeNone MessageType = iota 21 | MessageTypeConversation 22 | MessageTypeReply 23 | ) 24 | 25 | const ( 26 | ComposerSubmitted ComposerEvent = iota 27 | ComposerCancelled 28 | ) 29 | 30 | // Editor prompts 31 | const ( 32 | replyPrompt = "Compose your reply" 33 | conversationPrompt = "Start a new conversation" 34 | ) 35 | 36 | // Composer holds the state for a widget that creates new arbor nodes. 37 | type Composer struct { 38 | CommunityList layout.List 39 | Community widget.Enum 40 | 41 | SendButton, CancelButton, PasteButton widget.Clickable 42 | widget.Editor 43 | 44 | TextState richtext.InteractiveText 45 | 46 | ReplyingTo ds.ReplyData 47 | 48 | events []ComposerEvent 49 | composing bool 50 | messageType MessageType 51 | } 52 | 53 | // update handles all state processing. 54 | func (c *Composer) update(gtx layout.Context) { 55 | for _, e := range c.Editor.Events() { 56 | if _, ok := e.(widget.SubmitEvent); ok && !platform.Mobile { 57 | c.events = append(c.events, ComposerSubmitted) 58 | } 59 | } 60 | if c.PasteButton.Clicked(gtx) { 61 | clipboard.ReadOp{Tag: &c.composing}.Add(gtx.Ops) 62 | } 63 | for _, e := range gtx.Events(&c.composing) { 64 | switch e := e.(type) { 65 | case clipboard.Event: 66 | c.Editor.Insert(e.Text) 67 | } 68 | } 69 | if c.CancelButton.Clicked(gtx) { 70 | c.events = append(c.events, ComposerCancelled) 71 | } 72 | if c.SendButton.Clicked(gtx) { 73 | c.events = append(c.events, ComposerSubmitted) 74 | } 75 | } 76 | 77 | // Layout updates the state of the composer 78 | func (c *Composer) Layout(gtx layout.Context) layout.Dimensions { 79 | c.update(gtx) 80 | return layout.Dimensions{} 81 | } 82 | 83 | // StartReply configures the composer to write a reply to the provided 84 | // ReplyData. 85 | func (c *Composer) StartReply(to ds.ReplyData) { 86 | c.Reset() 87 | c.composing = true 88 | c.ReplyingTo = to 89 | c.Editor.Focus() 90 | } 91 | 92 | // StartConversation configures the composer to write a new conversation. 93 | func (c *Composer) StartConversation() { 94 | c.Reset() 95 | c.messageType = MessageTypeConversation 96 | c.composing = true 97 | c.Editor.Focus() 98 | } 99 | 100 | // Reset clears the internal state of the composer. 101 | func (c *Composer) Reset() { 102 | c.messageType = MessageTypeNone 103 | c.ReplyingTo = ds.ReplyData{} 104 | c.Editor.SetText("") 105 | c.composing = false 106 | } 107 | 108 | // ComposingConversation returns whether the composer is currently creating 109 | // a conversation (rather than a new reply within an existing conversation) 110 | func (c *Composer) ComposingConversation() bool { 111 | return (c.ReplyingTo.ID == nil || c.ReplyingTo.ID.Equals(fields.NullHash())) && c.Composing() 112 | } 113 | 114 | // Composing indicates whether the composer is composing a message of any 115 | // kind. 116 | func (c Composer) Composing() bool { 117 | return c.composing 118 | } 119 | 120 | // PromptText returns the text prompt for the composer, based off of the message type 121 | func (c Composer) PromptText() string { 122 | if c.messageType == MessageTypeConversation { 123 | return conversationPrompt 124 | } else { 125 | return replyPrompt 126 | } 127 | } 128 | 129 | func (c Composer) MessageType() MessageType { 130 | return c.messageType 131 | } 132 | 133 | // Events returns state change events for the composer since the last call 134 | // to events. 135 | func (c *Composer) Events() (out []ComposerEvent) { 136 | out, c.events = c.events, c.events[:0] 137 | return 138 | } 139 | -------------------------------------------------------------------------------- /widget/message-list.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "strings" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/widget" 8 | "gioui.org/x/markdown" 9 | "gioui.org/x/richtext" 10 | "git.sr.ht/~whereswaldon/forest-go/fields" 11 | "git.sr.ht/~whereswaldon/sprig/anim" 12 | "git.sr.ht/~whereswaldon/sprig/ds" 13 | ) 14 | 15 | // MessageListEventType is a kind of message list event. 16 | type MessageListEventType uint8 17 | 18 | const ( 19 | LinkOpen MessageListEventType = iota 20 | LinkLongPress 21 | ) 22 | 23 | // MessageListEvent describes a user interaction with the message list. 24 | type MessageListEvent struct { 25 | Type MessageListEventType 26 | // Data contains event-specific content: 27 | // - LinkOpened: the hyperlink being opened 28 | // - LinkLongPressed: the hyperlink that was longpressed 29 | Data string 30 | } 31 | 32 | type MessageList struct { 33 | widget.List 34 | textCache RichTextCache 35 | ReplyStates 36 | ShouldHide func(reply ds.ReplyData) bool 37 | StatusOf func(reply ds.ReplyData) ReplyStatus 38 | HiddenChildren func(reply ds.ReplyData) int 39 | UserIsActive func(identity *fields.QualifiedHash) bool 40 | Animation 41 | events []MessageListEvent 42 | } 43 | 44 | // GetTextState returns state storage for a node with the given ID, as well as hint text that should 45 | // be shown when rendering the given node (if any). 46 | func (m *MessageList) GetTextState(id *fields.QualifiedHash) (*richtext.InteractiveText, string) { 47 | state := m.textCache.Get(id) 48 | hint := "" 49 | for span, events := state.Events(); span != nil; span, events = state.Events() { 50 | for _, event := range events { 51 | url := span.Get(markdown.MetadataURL) 52 | switch event.Type { 53 | case richtext.Click: 54 | if asStr, ok := url.(string); ok { 55 | m.events = append(m.events, MessageListEvent{Type: LinkOpen, Data: asStr}) 56 | } 57 | case richtext.LongPress: 58 | if asStr, ok := url.(string); ok { 59 | m.events = append(m.events, MessageListEvent{Type: LinkLongPress, Data: asStr}) 60 | } 61 | fallthrough 62 | case richtext.Hover: 63 | if asStr, ok := url.(string); ok { 64 | hint = asStr 65 | } 66 | } 67 | } 68 | } 69 | return state, hint 70 | } 71 | 72 | // Layout updates the state of the message list each frame. 73 | func (m *MessageList) Layout(gtx layout.Context) layout.Dimensions { 74 | m.textCache.Frame() 75 | m.ReplyStates.Begin() 76 | m.List.Axis = layout.Vertical 77 | return layout.Dimensions{} 78 | } 79 | 80 | // Events returns user interactions with the message list that have occurred 81 | // since the last call to Events(). 82 | func (m *MessageList) Events() []MessageListEvent { 83 | out := m.events 84 | m.events = m.events[:0] 85 | return out 86 | } 87 | 88 | type ReplyStates = States[Reply] 89 | 90 | // States implements a buffer states such that memory 91 | // is reused each frame, yet grows as the view expands 92 | // to hold more values. 93 | type States[T any] struct { 94 | Buffer []T 95 | Current int 96 | } 97 | 98 | // Begin resets the buffer to the start. 99 | func (s *States[T]) Begin() { 100 | s.Current = 0 101 | } 102 | 103 | // Next returns the next available state to use, growing the underlying 104 | // buffer if necessary. 105 | func (s *States[T]) Next() *T { 106 | defer func() { s.Current++ }() 107 | if s.Current > len(s.Buffer)-1 { 108 | s.Buffer = append(s.Buffer, *new(T)) 109 | } 110 | return &s.Buffer[s.Current] 111 | } 112 | 113 | // Animation maintains animation states per reply. 114 | type Animation struct { 115 | anim.Normal 116 | animationInit bool 117 | Collection map[*fields.QualifiedHash]*ReplyAnimationState 118 | } 119 | 120 | func (a *Animation) init() { 121 | a.Collection = make(map[*fields.QualifiedHash]*ReplyAnimationState) 122 | a.animationInit = true 123 | } 124 | 125 | // Lookup animation state for the given reply. 126 | // If state doesn't exist, it will be created with using `s` as the 127 | // beginning status. 128 | func (a *Animation) Lookup(replyID *fields.QualifiedHash, s ReplyStatus) *ReplyAnimationState { 129 | if !a.animationInit { 130 | a.init() 131 | } 132 | _, ok := a.Collection[replyID] 133 | if !ok { 134 | a.Collection[replyID] = &ReplyAnimationState{ 135 | Normal: &a.Normal, 136 | Begin: s, 137 | } 138 | } 139 | return a.Collection[replyID] 140 | } 141 | 142 | // Update animation state for the given reply. 143 | func (a *Animation) Update(gtx layout.Context, replyID *fields.QualifiedHash, s ReplyStatus) *ReplyAnimationState { 144 | anim := a.Lookup(replyID, s) 145 | if a.Animating(gtx) { 146 | anim.End = s 147 | } else { 148 | anim.Begin = s 149 | anim.End = s 150 | } 151 | return anim 152 | } 153 | 154 | type ReplyStatus int 155 | 156 | const ( 157 | None ReplyStatus = 1 << iota 158 | Sibling 159 | Selected 160 | Ancestor 161 | Descendant 162 | ConversationRoot 163 | // Anchor indicates that this node is visible, but its descendants have been 164 | // hidden. 165 | Anchor 166 | // Hidden indicates that this node is not currently visible. 167 | Hidden 168 | ) 169 | 170 | func (r ReplyStatus) Contains(other ReplyStatus) bool { 171 | return r&other > 0 172 | } 173 | 174 | func (r ReplyStatus) String() string { 175 | var out []string 176 | if r.Contains(None) { 177 | out = append(out, "None") 178 | } 179 | if r.Contains(Sibling) { 180 | out = append(out, "Sibling") 181 | } 182 | if r.Contains(Selected) { 183 | out = append(out, "Selected") 184 | } 185 | if r.Contains(Ancestor) { 186 | out = append(out, "Ancestor") 187 | } 188 | if r.Contains(Descendant) { 189 | out = append(out, "Descendant") 190 | } 191 | if r.Contains(ConversationRoot) { 192 | out = append(out, "ConversationRoot") 193 | } 194 | if r.Contains(Anchor) { 195 | out = append(out, "Anchor") 196 | } 197 | if r.Contains(Hidden) { 198 | out = append(out, "Hidden") 199 | } 200 | return strings.Join(out, "|") 201 | } 202 | 203 | // ReplyAnimationState holds the state of an in-progress animation for a reply. 204 | // The anim.Normal field defines how far through the animation the node is, and 205 | // the Begin and End fields define the two states that the node is transitioning 206 | // between. 207 | type ReplyAnimationState struct { 208 | *anim.Normal 209 | Begin, End ReplyStatus 210 | } 211 | 212 | type CacheEntry struct { 213 | UsedSinceLastFrame bool 214 | richtext.InteractiveText 215 | } 216 | 217 | // RichTextCache holds rendered richtext state across frames, discarding any 218 | // state that is not used during a given frame. 219 | type RichTextCache struct { 220 | items map[*fields.QualifiedHash]*CacheEntry 221 | } 222 | 223 | func (r *RichTextCache) init() { 224 | r.items = make(map[*fields.QualifiedHash]*CacheEntry) 225 | } 226 | 227 | // Get returns richtext state for the given id if it exists, and allocates a new 228 | // state in the cache if it doesn't. 229 | func (r *RichTextCache) Get(id *fields.QualifiedHash) *richtext.InteractiveText { 230 | if r.items == nil { 231 | r.init() 232 | } 233 | if to, ok := r.items[id]; ok { 234 | r.items[id].UsedSinceLastFrame = true 235 | return &to.InteractiveText 236 | } 237 | r.items[id] = &CacheEntry{ 238 | UsedSinceLastFrame: true, 239 | } 240 | return &r.items[id].InteractiveText 241 | } 242 | 243 | // Frame purges cache entries that haven't been used since the last frame. 244 | func (r *RichTextCache) Frame() { 245 | for k, v := range r.items { 246 | if !v.UsedSinceLastFrame { 247 | delete(r.items, k) 248 | } else { 249 | v.UsedSinceLastFrame = false 250 | } 251 | } 252 | } 253 | -------------------------------------------------------------------------------- /widget/polyclick.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "image" 5 | "time" 6 | 7 | "gioui.org/gesture" 8 | "gioui.org/io/pointer" 9 | "gioui.org/layout" 10 | "gioui.org/op" 11 | "gioui.org/op/clip" 12 | "gioui.org/widget" 13 | ) 14 | 15 | // Polyclick can detect and report a variety of gesture interactions 16 | // within a single pointer input area. 17 | type Polyclick struct { 18 | // The zero value will pass through pointer events by default. 19 | NoPass bool 20 | gesture.Click 21 | clicks []widget.Click 22 | pressed, longPressReported bool 23 | pressStart time.Time 24 | currentTime time.Time 25 | } 26 | 27 | func (p *Polyclick) update(gtx layout.Context) { 28 | p.currentTime = gtx.Now 29 | for _, event := range p.Click.Update(gtx) { 30 | switch event.Kind { 31 | case gesture.KindCancel: 32 | p.processCancel(event, gtx) 33 | case gesture.KindPress: 34 | p.processPress(event, gtx) 35 | case gesture.KindClick: 36 | p.processClick(event, gtx) 37 | default: 38 | continue 39 | } 40 | } 41 | } 42 | 43 | func (p *Polyclick) processCancel(event gesture.ClickEvent, gtx layout.Context) { 44 | p.pressed = false 45 | p.longPressReported = false 46 | } 47 | func (p *Polyclick) processPress(event gesture.ClickEvent, gtx layout.Context) { 48 | p.pressed = true 49 | p.pressStart = gtx.Now 50 | } 51 | func (p *Polyclick) processClick(event gesture.ClickEvent, gtx layout.Context) { 52 | p.pressed = false 53 | if !p.longPressReported { 54 | p.clicks = append(p.clicks, widget.Click{ 55 | Modifiers: event.Modifiers, 56 | NumClicks: event.NumClicks, 57 | }) 58 | } 59 | p.longPressReported = false 60 | } 61 | 62 | func (p *Polyclick) Clicks() (out []widget.Click) { 63 | out, p.clicks = p.clicks, p.clicks[:0] 64 | return 65 | } 66 | 67 | func (p *Polyclick) LongPressed() bool { 68 | elapsed := p.currentTime.Sub(p.pressStart) 69 | if !p.longPressReported && p.pressed && elapsed > time.Millisecond*250 { 70 | p.longPressReported = true 71 | return true 72 | } 73 | return false 74 | } 75 | 76 | func (p *Polyclick) Layout(gtx layout.Context) layout.Dimensions { 77 | p.update(gtx) 78 | defer clip.Rect(image.Rectangle{Max: gtx.Constraints.Min}).Push(gtx.Ops).Pop() 79 | defer pointer.PassOp{}.Push(gtx.Ops).Pop() 80 | p.Click.Add(gtx.Ops) 81 | if p.pressed { 82 | op.InvalidateOp{}.Add(gtx.Ops) 83 | } 84 | return layout.Dimensions{Size: gtx.Constraints.Min} 85 | } 86 | -------------------------------------------------------------------------------- /widget/reply.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "gioui.org/gesture" 5 | "gioui.org/io/pointer" 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | "gioui.org/x/richtext" 9 | "git.sr.ht/~whereswaldon/forest-go/fields" 10 | ) 11 | 12 | // Reply holds ui state for each reply. 13 | type Reply struct { 14 | Hash *fields.QualifiedHash 15 | Content string 16 | Polyclick 17 | richtext.InteractiveText 18 | ReplyStatus 19 | gesture.Drag 20 | dragStart, dragOffset float32 21 | dragFinished bool 22 | events []ReplyEvent 23 | } 24 | 25 | func (r *Reply) WithHash(h *fields.QualifiedHash) *Reply { 26 | r.Hash = h 27 | return r 28 | } 29 | 30 | func (r *Reply) WithContent(s string) *Reply { 31 | r.Content = s 32 | return r 33 | } 34 | 35 | // Layout adds the drag operation (using the most recently laid out 36 | // pointer hit area) and processes drag status. 37 | func (r *Reply) Layout(gtx layout.Context, replyWidth int) layout.Dimensions { 38 | r.Drag.Add(gtx.Ops) 39 | 40 | for _, e := range r.Drag.Update(gtx.Metric, gtx, gesture.Horizontal) { 41 | switch e.Kind { 42 | case pointer.Press: 43 | r.dragStart = e.Position.X 44 | r.dragOffset = 0 45 | r.dragFinished = false 46 | case pointer.Drag: 47 | r.dragOffset = e.Position.X - r.dragStart 48 | case pointer.Release, pointer.Cancel: 49 | r.dragStart = 0 50 | r.dragOffset = 0 51 | r.dragFinished = false 52 | } 53 | } 54 | 55 | if r.Dragging() { 56 | op.InvalidateOp{}.Add(gtx.Ops) 57 | } 58 | 59 | if r.dragOffset < 0 { 60 | r.dragOffset = 0 61 | } 62 | if replyWidth+int(r.dragOffset) >= gtx.Constraints.Max.X { 63 | r.dragOffset = float32(gtx.Constraints.Max.X - replyWidth) 64 | if !r.dragFinished { 65 | r.events = append(r.events, ReplyEvent{Type: SwipedRight}) 66 | r.dragFinished = true 67 | } 68 | } 69 | return layout.Dimensions{} 70 | } 71 | 72 | // DragOffset returns the X-axis offset for this reply as a result of a user 73 | // dragging it. 74 | func (r *Reply) DragOffset() float32 { 75 | return r.dragOffset 76 | } 77 | 78 | // Events returns reply events that have occurred since the last call to Events. 79 | func (r *Reply) Events() []ReplyEvent { 80 | events := r.events 81 | r.events = r.events[:0] 82 | return events 83 | } 84 | 85 | // ReplyEvent models a change or interaction with a reply. 86 | type ReplyEvent struct { 87 | Type ReplyEventType 88 | } 89 | 90 | // ReplyEventType encodes a kind of event. 91 | type ReplyEventType uint8 92 | 93 | const ( 94 | // SwipedRight indicates that a given reply was swiped to the right margin 95 | // by a user. 96 | SwipedRight ReplyEventType = iota 97 | ) 98 | -------------------------------------------------------------------------------- /widget/text-form.go: -------------------------------------------------------------------------------- 1 | package widget 2 | 3 | import ( 4 | "gioui.org/io/clipboard" 5 | "gioui.org/layout" 6 | "gioui.org/widget" 7 | materials "gioui.org/x/component" 8 | ) 9 | 10 | // TextForm holds the theme-independent state of a simple form that 11 | // allows a user to provide a single text value and supports pasting. 12 | // It can be submitted with either the submit button or pressing enter 13 | // on the keyboard. 14 | type TextForm struct { 15 | submitted bool 16 | TextField materials.TextField 17 | SubmitButton widget.Clickable 18 | PasteButton widget.Clickable 19 | } 20 | 21 | func (c *TextForm) Layout(gtx layout.Context) layout.Dimensions { 22 | c.submitted = false 23 | for _, e := range c.TextField.Events() { 24 | if _, ok := e.(widget.SubmitEvent); ok { 25 | c.submitted = true 26 | } 27 | } 28 | if c.SubmitButton.Clicked(gtx) { 29 | c.submitted = true 30 | } 31 | if c.PasteButton.Clicked(gtx) { 32 | clipboard.ReadOp{Tag: c}.Add(gtx.Ops) 33 | } 34 | for _, e := range gtx.Events(c) { 35 | switch e := e.(type) { 36 | case clipboard.Event: 37 | c.TextField.Editor.Insert(e.Text) 38 | } 39 | } 40 | return layout.Dimensions{} 41 | } 42 | 43 | func (c *TextForm) Submitted() bool { 44 | return c.submitted 45 | } 46 | -------------------------------------------------------------------------------- /widget/theme/composer.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "gioui.org/f32" 5 | "gioui.org/layout" 6 | "gioui.org/unit" 7 | "gioui.org/widget/material" 8 | "gioui.org/x/markdown" 9 | "gioui.org/x/richtext" 10 | "git.sr.ht/~whereswaldon/forest-go" 11 | "git.sr.ht/~whereswaldon/sprig/icons" 12 | sprigWidget "git.sr.ht/~whereswaldon/sprig/widget" 13 | ) 14 | 15 | type ComposerStyle struct { 16 | *sprigWidget.Composer 17 | *Theme 18 | Communities []*forest.Community 19 | } 20 | 21 | func Composer(th *Theme, state *sprigWidget.Composer, communities []*forest.Community) ComposerStyle { 22 | return ComposerStyle{ 23 | Composer: state, 24 | Theme: th, 25 | Communities: communities, 26 | } 27 | } 28 | 29 | func (c ComposerStyle) Layout(gtx layout.Context) layout.Dimensions { 30 | th := c.Theme 31 | c.Composer.Layout(gtx) 32 | return layout.Stack{}.Layout(gtx, 33 | layout.Expanded(func(gtx C) D { 34 | Rect{ 35 | Color: th.Primary.Light.Bg, 36 | Size: f32.Point{ 37 | X: float32(gtx.Constraints.Max.X), 38 | Y: float32(gtx.Constraints.Max.Y), 39 | }, 40 | }.Layout(gtx) 41 | return layout.Dimensions{} 42 | }), 43 | layout.Stacked(func(gtx C) D { 44 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 45 | layout.Rigid(func(gtx C) D { 46 | return layout.Flex{}.Layout(gtx, 47 | layout.Rigid(func(gtx C) D { 48 | return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D { 49 | gtx.Constraints.Max.X = gtx.Dp(unit.Dp(30)) 50 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 51 | if c.ComposingConversation() { 52 | return material.Body1(th.Theme, "In:").Layout(gtx) 53 | } 54 | return material.Body1(th.Theme, "Re:").Layout(gtx) 55 | }) 56 | }), 57 | layout.Flexed(1, func(gtx C) D { 58 | return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D { 59 | if c.ComposingConversation() { 60 | var dims layout.Dimensions 61 | dims = c.CommunityList.Layout(gtx, len(c.Communities), func(gtx layout.Context, index int) layout.Dimensions { 62 | community := c.Communities[index] 63 | if c.Community.Value == "" && index == 0 { 64 | c.Community.Value = community.ID().String() 65 | } 66 | radio := material.RadioButton(th.Theme, &c.Community, community.ID().String(), string(community.Name.Blob)) 67 | radio.IconColor = th.Secondary.Default.Bg 68 | return radio.Layout(gtx) 69 | }) 70 | return dims 71 | } 72 | content, _ := markdown.NewRenderer().Render([]byte(c.ReplyingTo.Content)) 73 | reply := Reply(th, nil, c.ReplyingTo, richtext.Text(&c.Composer.TextState, th.Shaper, content...), false) 74 | reply.MaxLines = 5 75 | return reply.Layout(gtx) 76 | }) 77 | }), 78 | layout.Rigid(func(gtx C) D { 79 | return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D { 80 | return IconButton{ 81 | Button: &c.CancelButton, 82 | Icon: icons.CancelReplyIcon, 83 | }.Layout(gtx, th) 84 | }) 85 | }), 86 | ) 87 | }), 88 | layout.Rigid(func(gtx C) D { 89 | return layout.Flex{}.Layout(gtx, 90 | layout.Rigid(func(gtx C) D { 91 | return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D { 92 | return IconButton{ 93 | Button: &c.PasteButton, 94 | Icon: icons.PasteIcon, 95 | }.Layout(gtx, th) 96 | }) 97 | }), 98 | layout.Flexed(1, func(gtx C) D { 99 | return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D { 100 | return layout.Stack{}.Layout(gtx, 101 | layout.Expanded(func(gtx C) D { 102 | return Rect{ 103 | Color: th.Background.Light.Bg, 104 | Size: f32.Point{ 105 | X: float32(gtx.Constraints.Max.X), 106 | Y: float32(gtx.Constraints.Min.Y), 107 | }, 108 | Radii: float32(gtx.Dp(unit.Dp(5))), 109 | }.Layout(gtx) 110 | }), 111 | layout.Stacked(func(gtx C) D { 112 | return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D { 113 | c.Editor.Submit = true 114 | return material.Editor(th.Theme, &c.Editor, c.PromptText()).Layout(gtx) 115 | }) 116 | }), 117 | ) 118 | }) 119 | }), 120 | layout.Rigid(func(gtx C) D { 121 | return layout.UniformInset(unit.Dp(6)).Layout(gtx, func(gtx C) D { 122 | return IconButton{ 123 | Button: &c.SendButton, 124 | Icon: icons.SendReplyIcon, 125 | }.Layout(gtx, th) 126 | }) 127 | }), 128 | ) 129 | }), 130 | ) 131 | }), 132 | ) 133 | } 134 | -------------------------------------------------------------------------------- /widget/theme/fonts/static/NotoEmoji-Regular.ttf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/arborchat/sprig/23645d553dd791d8c37ebb86dada716a32e5e6f7/widget/theme/fonts/static/NotoEmoji-Regular.ttf -------------------------------------------------------------------------------- /widget/theme/icon-button.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "gioui.org/layout" 5 | "gioui.org/unit" 6 | "gioui.org/widget" 7 | "gioui.org/widget/material" 8 | ) 9 | 10 | // IconButton applies defaults before rendering a `material.IconButtonStyle` to reduce noise. 11 | // The main paramaters for each button are the state and icon. 12 | // Color, size and inset are often the same. 13 | // This wrapper reduces noise by defaulting those things. 14 | type IconButton struct { 15 | Button *widget.Clickable 16 | Icon *widget.Icon 17 | Size unit.Dp 18 | Inset layout.Inset 19 | } 20 | 21 | const DefaultIconButtonWidthDp = 20 22 | 23 | func (btn IconButton) Layout(gtx C, th *Theme) D { 24 | if btn.Size == 0 { 25 | btn.Size = unit.Dp(DefaultIconButtonWidthDp) 26 | } 27 | if btn.Inset == (layout.Inset{}) { 28 | btn.Inset = layout.UniformInset(unit.Dp(4)) 29 | } 30 | 31 | return material.IconButtonStyle{ 32 | Background: th.Palette.ContrastBg, 33 | Color: th.Palette.ContrastFg, 34 | Icon: btn.Icon, 35 | Size: btn.Size, 36 | Inset: btn.Inset, 37 | Button: btn.Button, 38 | }.Layout(gtx) 39 | } 40 | -------------------------------------------------------------------------------- /widget/theme/message-list.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/op" 8 | "gioui.org/unit" 9 | "gioui.org/widget" 10 | "gioui.org/widget/material" 11 | "gioui.org/x/component" 12 | "gioui.org/x/markdown" 13 | "gioui.org/x/richtext" 14 | "git.sr.ht/~whereswaldon/sprig/ds" 15 | "git.sr.ht/~whereswaldon/sprig/icons" 16 | sprigWidget "git.sr.ht/~whereswaldon/sprig/widget" 17 | ) 18 | 19 | type MessageListStyle struct { 20 | *Theme 21 | State *sprigWidget.MessageList 22 | Replies []ds.ReplyData 23 | Prefixes []layout.Widget 24 | CreateReplyButton *widget.Clickable 25 | material.ListStyle 26 | } 27 | 28 | func MessageList(th *Theme, state *sprigWidget.MessageList, replyBtn *widget.Clickable, replies []ds.ReplyData) MessageListStyle { 29 | mls := MessageListStyle{ 30 | Theme: th, 31 | State: state, 32 | Replies: replies, 33 | CreateReplyButton: replyBtn, 34 | ListStyle: material.List(th.Theme, &state.List), 35 | } 36 | mls.ListStyle.Indicator.MajorMinLen = unit.Dp(12) 37 | return mls 38 | } 39 | 40 | const insetUnit = 12 41 | 42 | var ( 43 | defaultInset = unit.Dp(insetUnit) 44 | ancestorInset = unit.Dp(2 * insetUnit) 45 | selectedInset = unit.Dp(2 * insetUnit) 46 | descendantInset = unit.Dp(3 * insetUnit) 47 | ) 48 | 49 | // MaxReplyInset returns the maximum distance that a reply will be inset 50 | // based on its position within the message tree. 51 | func MaxReplyInset() unit.Dp { 52 | return descendantInset 53 | } 54 | 55 | func insetForStatus(status sprigWidget.ReplyStatus) unit.Dp { 56 | switch { 57 | case status&sprigWidget.Selected > 0: 58 | return selectedInset 59 | case status&sprigWidget.Ancestor > 0: 60 | return ancestorInset 61 | case status&sprigWidget.Descendant > 0: 62 | return descendantInset 63 | case status&sprigWidget.Sibling > 0: 64 | return defaultInset 65 | default: 66 | return defaultInset 67 | } 68 | } 69 | 70 | func interpolateInset(anim *sprigWidget.ReplyAnimationState, progress float32) unit.Dp { 71 | if progress == 0 { 72 | return insetForStatus(anim.Begin) 73 | } 74 | begin := insetForStatus(anim.Begin) 75 | end := insetForStatus(anim.End) 76 | return unit.Dp((end-begin)*unit.Dp(progress) + begin) 77 | } 78 | 79 | const ( 80 | buttonWidthDp = 20 81 | scrollSlotWidthDp = 12 82 | ) 83 | 84 | func (m MessageListStyle) Layout(gtx C) D { 85 | m.State.Layout(gtx) 86 | th := m.Theme 87 | dims := m.ListStyle.Layout(gtx, len(m.Replies)+len(m.Prefixes), func(gtx layout.Context, index int) layout.Dimensions { 88 | if index < len(m.Prefixes) { 89 | return m.Prefixes[index](gtx) 90 | } 91 | // adjust to be a valid reply index 92 | index -= len(m.Prefixes) 93 | reply := m.Replies[index] 94 | 95 | // return as soon as possible if this node shouldn't be displayed 96 | if m.State.ShouldHide != nil && m.State.ShouldHide(reply) { 97 | return layout.Dimensions{} 98 | } 99 | var status sprigWidget.ReplyStatus 100 | if m.State.StatusOf != nil { 101 | status = m.State.StatusOf(reply) 102 | } 103 | var ( 104 | anim = m.State.Animation.Update(gtx, reply.ID, status) 105 | isActive bool 106 | collapseMetadata = func() bool { 107 | // This conflicts with animation feature, so we're removing the feature for now. 108 | // if index > 0 { 109 | // if replies[index-1].Reply.Author.Equals(&reply.Reply.Author) && replies[index-1].ID().Equals(reply.ParentID()) { 110 | // return true 111 | // } 112 | // } 113 | return false 114 | }() 115 | ) 116 | if m.State.UserIsActive != nil { 117 | isActive = m.State.UserIsActive(reply.AuthorID) 118 | } 119 | // Only acquire a state after ensuring the node should be rendered. This allows 120 | // us to count used states in order to determine how many nodes were rendered. 121 | state := m.State.ReplyStates.Next() 122 | return layout.Center.Layout(gtx, func(gtx C) D { 123 | var ( 124 | cs = >x.Constraints 125 | contentMax = gtx.Dp(unit.Dp(800)) 126 | ) 127 | if cs.Max.X > contentMax { 128 | cs.Max.X = contentMax 129 | } 130 | return layout.Stack{}.Layout(gtx, 131 | layout.Stacked(func(gtx C) D { 132 | var ( 133 | extraWidth = gtx.Dp(unit.Dp(5*insetUnit + DefaultIconButtonWidthDp + scrollSlotWidthDp)) 134 | messageWidth = gtx.Constraints.Max.X - extraWidth 135 | ) 136 | dims := layout.Stack{}.Layout(gtx, 137 | layout.Stacked(func(gtx C) D { 138 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 139 | return layout.Inset{ 140 | Top: func() unit.Dp { 141 | if collapseMetadata { 142 | return unit.Dp(0) 143 | } 144 | return unit.Dp(3) 145 | }(), 146 | Bottom: unit.Dp(3), 147 | Left: interpolateInset(anim, m.State.Animation.Progress(gtx)), 148 | }.Layout(gtx, func(gtx C) D { 149 | gtx.Constraints.Max.X = messageWidth 150 | state, hint := m.State.GetTextState(reply.ID) 151 | content, _ := markdown.NewRenderer().Render([]byte(reply.Content)) 152 | if hint != "" { 153 | macro := op.Record(gtx.Ops) 154 | component.Surface(th.Theme).Layout(gtx, 155 | func(gtx C) D { 156 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, material.Body2(th.Theme, hint).Layout) 157 | }) 158 | op.Defer(gtx.Ops, macro.Stop()) 159 | } 160 | rs := Reply(th, anim, reply, richtext.Text(state, th.Shaper, content...), isActive). 161 | HideMetadata(collapseMetadata) 162 | if anim.Begin&sprigWidget.Anchor > 0 { 163 | rs = rs.Anchoring(th.Theme, m.State.HiddenChildren(reply)) 164 | } 165 | 166 | return rs.Layout(gtx) 167 | }) 168 | }), 169 | layout.Expanded(func(gtx C) D { 170 | return state. 171 | WithHash(reply.ID). 172 | WithContent(reply.Content). 173 | Polyclick. 174 | Layout(gtx) 175 | }), 176 | ) 177 | return D{ 178 | Size: image.Point{ 179 | X: gtx.Constraints.Max.X, 180 | Y: dims.Size.Y, 181 | }, 182 | Baseline: dims.Baseline, 183 | } 184 | }), 185 | layout.Expanded(func(gtx C) D { 186 | return layout.E.Layout(gtx, func(gtx C) D { 187 | if status != sprigWidget.Selected { 188 | return D{} 189 | } 190 | return layout.Inset{ 191 | Right: unit.Dp(scrollSlotWidthDp), 192 | }.Layout(gtx, func(gtx C) D { 193 | return material.IconButtonStyle{ 194 | Background: th.Secondary.Light.Bg, 195 | Color: th.Secondary.Light.Fg, 196 | Button: m.CreateReplyButton, 197 | Icon: icons.ReplyIcon, 198 | Size: unit.Dp(DefaultIconButtonWidthDp), 199 | Inset: layout.UniformInset(unit.Dp(9)), 200 | }.Layout(gtx) 201 | }) 202 | }) 203 | }), 204 | ) 205 | }) 206 | }) 207 | return dims 208 | } 209 | -------------------------------------------------------------------------------- /widget/theme/reply.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "encoding/hex" 5 | "fmt" 6 | "image/color" 7 | 8 | "gioui.org/font" 9 | "gioui.org/layout" 10 | "gioui.org/op" 11 | "gioui.org/unit" 12 | "gioui.org/widget" 13 | "gioui.org/widget/material" 14 | materials "gioui.org/x/component" 15 | "gioui.org/x/richtext" 16 | "git.sr.ht/~whereswaldon/forest-go/fields" 17 | "git.sr.ht/~whereswaldon/sprig/ds" 18 | sprigWidget "git.sr.ht/~whereswaldon/sprig/widget" 19 | ) 20 | 21 | type ( 22 | C = layout.Context 23 | D = layout.Dimensions 24 | ) 25 | 26 | func HighlightColor(r sprigWidget.ReplyStatus, th *Theme) color.NRGBA { 27 | var c color.NRGBA 28 | switch { 29 | case r&sprigWidget.Selected > 0: 30 | c = *th.Selected 31 | case r&sprigWidget.Ancestor > 0: 32 | c = *th.Ancestors 33 | case r&sprigWidget.Descendant > 0: 34 | c = *th.Descendants 35 | case r&sprigWidget.Sibling > 0: 36 | c = *th.Siblings 37 | default: 38 | c = *th.Unselected 39 | } 40 | return c 41 | } 42 | 43 | func ReplyTextColor(r sprigWidget.ReplyStatus, th *Theme) color.NRGBA { 44 | switch { 45 | case r&sprigWidget.Anchor > 0: 46 | c := th.Theme.Fg 47 | c.A = 150 48 | return c 49 | case r&sprigWidget.Hidden > 0: 50 | c := th.Theme.Fg 51 | c.A = 0 52 | return c 53 | default: 54 | return th.Theme.Fg 55 | } 56 | } 57 | 58 | func BorderColor(r sprigWidget.ReplyStatus, th *Theme) color.NRGBA { 59 | var c color.NRGBA 60 | switch { 61 | case r&sprigWidget.Selected > 0: 62 | c = *th.Selected 63 | default: 64 | c = th.Background.Light.Bg 65 | } 66 | if r&sprigWidget.Anchor > 0 { 67 | c.A = 150 68 | } 69 | return c 70 | } 71 | 72 | func BackgroundColor(r sprigWidget.ReplyStatus, th *Theme) color.NRGBA { 73 | switch { 74 | case r&sprigWidget.Anchor > 0: 75 | c := th.Background.Light.Bg 76 | c.A = 150 77 | return c 78 | default: 79 | return th.Background.Light.Bg 80 | } 81 | } 82 | 83 | // ReplyStyleConfig configures aspects of the presentation of a message. 84 | type ReplyStyleConfig struct { 85 | Highlight color.NRGBA 86 | Background color.NRGBA 87 | TextColor color.NRGBA 88 | Border color.NRGBA 89 | highlightWidth unit.Dp 90 | } 91 | 92 | // ReplyStyleConfigFor returns a configuration tailored to the given ReplyStatus 93 | // and theme. 94 | func ReplyStyleConfigFor(th *Theme, status sprigWidget.ReplyStatus) ReplyStyleConfig { 95 | return ReplyStyleConfig{ 96 | Highlight: HighlightColor(status, th), 97 | Background: BackgroundColor(status, th), 98 | TextColor: ReplyTextColor(status, th), 99 | Border: BorderColor(status, th), 100 | highlightWidth: unit.Dp(10), 101 | } 102 | } 103 | 104 | // ReplyStyleTransition represents a transition from one ReplyStyleConfig to 105 | // another one and provides a method for interpolating the intermediate 106 | // results between them. 107 | type ReplyStyleTransition struct { 108 | Previous, Current ReplyStyleConfig 109 | } 110 | 111 | // InterpolateWith returns a ReplyStyleConfig blended between the previous 112 | // and current configurations, with 0 returning the previous configuration 113 | // and 1 returning the current. 114 | func (r ReplyStyleTransition) InterpolateWith(progress float32) ReplyStyleConfig { 115 | return ReplyStyleConfig{ 116 | Highlight: materials.Interpolate(r.Previous.Highlight, r.Current.Highlight, progress), 117 | Background: materials.Interpolate(r.Previous.Background, r.Current.Background, progress), 118 | TextColor: materials.Interpolate(r.Previous.TextColor, r.Current.TextColor, progress), 119 | Border: materials.Interpolate(r.Previous.Border, r.Current.Border, progress), 120 | highlightWidth: r.Current.highlightWidth, 121 | } 122 | } 123 | 124 | // ReplyStyle presents a reply as a formatted chat bubble. 125 | type ReplyStyle struct { 126 | // ReplyStyleTransition captures the two states that the ReplyStyle is 127 | // transitioning between (though it may not currently be transitioning). 128 | ReplyStyleTransition 129 | 130 | // finalConfig is the results of interpolating between the two states in 131 | // the ReplyStyleTransition. Its value can only be determined and used at 132 | // layout time. 133 | finalConfig ReplyStyleConfig 134 | 135 | // Background color for the status badge (currently only used if root node) 136 | BadgeColor color.NRGBA 137 | // Text config for the status badge 138 | BadgeText material.LabelStyle 139 | 140 | // MaxLines limits the maximum number of lines of content text that should 141 | // be displayed. Values less than 1 indicate unlimited. 142 | MaxLines int 143 | 144 | // CollapseMetadata should be set to true if this reply can be rendered 145 | // without the author being displayed. 146 | CollapseMetadata bool 147 | 148 | *sprigWidget.ReplyAnimationState 149 | 150 | ds.ReplyData 151 | // Whether or not to render the user as active 152 | ShowActive bool 153 | 154 | // Special text to overlay atop the message contents. Used for displaying 155 | // messages on anchor nodes with hidden children. 156 | AnchorText material.LabelStyle 157 | 158 | Content richtext.TextStyle 159 | 160 | AuthorNameStyle 161 | CommunityNameStyle ForestRefStyle 162 | DateStyle material.LabelStyle 163 | 164 | // Padding configures the padding surrounding the entire interior content of the 165 | // rendered message. 166 | Padding layout.Inset 167 | 168 | // MetadataPadding configures the padding surrounding the metadata line of a node. 169 | MetadataPadding layout.Inset 170 | } 171 | 172 | // Reply configures a ReplyStyle for the provided state. 173 | func Reply(th *Theme, status *sprigWidget.ReplyAnimationState, nodes ds.ReplyData, text richtext.TextStyle, showActive bool) ReplyStyle { 174 | rs := ReplyStyle{ 175 | ReplyData: nodes, 176 | ReplyAnimationState: status, 177 | ShowActive: showActive, 178 | Content: text, 179 | BadgeColor: th.Primary.Dark.Bg, 180 | AuthorNameStyle: AuthorName(th, nodes.AuthorName, nodes.AuthorID, showActive), 181 | CommunityNameStyle: CommunityName(th.Theme, nodes.CommunityName, nodes.CommunityID), 182 | Padding: layout.UniformInset(unit.Dp(8)), 183 | MetadataPadding: layout.Inset{Bottom: unit.Dp(4)}, 184 | } 185 | if status != nil { 186 | rs.ReplyStyleTransition = ReplyStyleTransition{ 187 | Previous: ReplyStyleConfigFor(th, status.Begin), 188 | Current: ReplyStyleConfigFor(th, status.End), 189 | } 190 | } else { 191 | status := sprigWidget.None 192 | rs.ReplyStyleTransition = ReplyStyleTransition{ 193 | Previous: ReplyStyleConfigFor(th, status), 194 | Current: ReplyStyleConfigFor(th, status), 195 | } 196 | } 197 | if nodes.Depth == 1 { 198 | theme := *th.Theme 199 | theme.Palette = ApplyAsNormal(th.Palette, th.Primary.Dark) 200 | rs.BadgeText = material.Body2(&theme, "Root") 201 | } 202 | rs.DateStyle = material.Body2(th.Theme, nodes.CreatedAt.Local().Format("2006/01/02 15:04")) 203 | rs.DateStyle.MaxLines = 1 204 | rs.DateStyle.Color.A = 200 205 | rs.DateStyle.TextSize = unit.Sp(12) 206 | return rs 207 | } 208 | 209 | // Anchoring modifies the ReplyStyle to indicate that it is hiding some number 210 | // of other nodes. 211 | func (r ReplyStyle) Anchoring(th *material.Theme, numNodes int) ReplyStyle { 212 | r.AnchorText = material.Body1(th, fmt.Sprintf("hidden replies: %d", numNodes)) 213 | return r 214 | } 215 | 216 | // Layout renders the ReplyStyle. 217 | func (r ReplyStyle) Layout(gtx layout.Context) layout.Dimensions { 218 | var progress float32 219 | if r.ReplyAnimationState != nil { 220 | progress = r.ReplyAnimationState.Progress(gtx) 221 | } else { 222 | progress = 1 223 | } 224 | r.finalConfig = r.ReplyStyleTransition.InterpolateWith(progress) 225 | radiiDp := unit.Dp(5) 226 | radii := float32(gtx.Dp(radiiDp)) 227 | return layout.Stack{}.Layout(gtx, 228 | layout.Expanded(func(gtx C) D { 229 | innerSize := gtx.Constraints.Min 230 | return widget.Border{ 231 | Color: r.finalConfig.Border, 232 | Width: unit.Dp(2), 233 | CornerRadius: radiiDp, 234 | }.Layout(gtx, func(gtx C) D { 235 | return Rect{Color: r.finalConfig.Background, Size: layout.FPt(innerSize), Radii: radii}.Layout(gtx) 236 | }) 237 | }), 238 | layout.Stacked(func(gtx C) D { 239 | return layout.Stack{}.Layout(gtx, 240 | layout.Expanded(func(gtx C) D { 241 | max := layout.FPt(gtx.Constraints.Min) 242 | max.X = float32(gtx.Dp(r.finalConfig.highlightWidth)) 243 | return Rect{Color: r.finalConfig.Highlight, Size: max, Radii: radii}.Layout(gtx) 244 | }), 245 | layout.Stacked(func(gtx C) D { 246 | inset := layout.Inset{} 247 | inset.Left = r.finalConfig.highlightWidth + inset.Left 248 | isConversationRoot := r.ReplyData.Depth == 1 249 | return inset.Layout(gtx, func(gtx C) D { 250 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 251 | layout.Rigid(func(gtx C) D { 252 | return r.Padding.Layout(gtx, r.layoutContents) 253 | }), 254 | layout.Rigid(func(gtx C) D { 255 | if isConversationRoot { 256 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 257 | return layout.SE.Layout(gtx, func(gtx C) D { 258 | return layout.Stack{}.Layout(gtx, 259 | layout.Expanded(func(gtx C) D { 260 | return Rect{Color: r.BadgeColor, Size: layout.FPt(gtx.Constraints.Min), Radii: radii}.Layout(gtx) 261 | }), 262 | layout.Stacked(func(gtx C) D { 263 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, r.BadgeText.Layout) 264 | }), 265 | ) 266 | }) 267 | } 268 | return D{} 269 | }), 270 | ) 271 | }) 272 | }), 273 | layout.Expanded(func(gtx C) D { 274 | if r.AnchorText == (material.LabelStyle{}) { 275 | return D{} 276 | } 277 | return layout.Center.Layout(gtx, func(gtx C) D { 278 | return layout.Stack{}.Layout(gtx, 279 | layout.Expanded(func(gtx C) D { 280 | max := layout.FPt(gtx.Constraints.Min) 281 | color := r.finalConfig.Background 282 | color.A = 0xff 283 | return Rect{Color: color, Size: max, Radii: radii}.Layout(gtx) 284 | }), 285 | layout.Stacked(func(gtx C) D { 286 | return layout.UniformInset(unit.Dp(4)).Layout(gtx, r.AnchorText.Layout) 287 | }), 288 | ) 289 | }) 290 | }), 291 | ) 292 | }), 293 | ) 294 | } 295 | 296 | // HideMetadata configures the node metadata line to not be displayed in 297 | // the reply. 298 | func (r ReplyStyle) HideMetadata(b bool) ReplyStyle { 299 | r.CollapseMetadata = b 300 | return r 301 | } 302 | 303 | func max(is ...int) int { 304 | max := is[0] 305 | for i := range is { 306 | if i > max { 307 | max = i 308 | } 309 | } 310 | return max 311 | } 312 | 313 | func (r ReplyStyle) layoutMetadata(gtx layout.Context) layout.Dimensions { 314 | inset := layout.Inset{Right: unit.Dp(4)} 315 | nameMacro := op.Record(gtx.Ops) 316 | author := r.AuthorNameStyle 317 | author.NameStyle.Color = r.finalConfig.TextColor 318 | author.SuffixStyle.Color = r.finalConfig.TextColor 319 | author.ActivityIndicatorStyle.Color.A = r.finalConfig.TextColor.A 320 | nameDim := inset.Layout(gtx, author.Layout) 321 | nameWidget := nameMacro.Stop() 322 | 323 | communityMacro := op.Record(gtx.Ops) 324 | comm := r.CommunityNameStyle 325 | comm.NameStyle.Color = r.finalConfig.TextColor 326 | comm.SuffixStyle.Color = r.finalConfig.TextColor 327 | communityDim := inset.Layout(gtx, comm.Layout) 328 | communityWidget := communityMacro.Stop() 329 | 330 | dateMacro := op.Record(gtx.Ops) 331 | dateDim := r.DateStyle.Layout(gtx) 332 | dateWidget := dateMacro.Stop() 333 | 334 | gtx.Constraints.Min.Y = max(nameDim.Size.Y, communityDim.Size.Y, dateDim.Size.Y) 335 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 336 | 337 | shouldDisplayDate := gtx.Constraints.Max.X-nameDim.Size.X > dateDim.Size.X 338 | shouldDisplayCommunity := shouldDisplayDate && gtx.Constraints.Max.X-(nameDim.Size.X+dateDim.Size.X) > communityDim.Size.X 339 | 340 | flexChildren := []layout.FlexChild{ 341 | layout.Rigid(func(gtx C) D { 342 | return layout.S.Layout(gtx, func(gtx C) D { 343 | nameWidget.Add(gtx.Ops) 344 | return nameDim 345 | }) 346 | }), 347 | } 348 | if shouldDisplayCommunity { 349 | flexChildren = append(flexChildren, 350 | layout.Rigid(func(gtx C) D { 351 | return layout.S.Layout(gtx, func(gtx C) D { 352 | communityWidget.Add(gtx.Ops) 353 | return communityDim 354 | }) 355 | }), 356 | ) 357 | } 358 | if shouldDisplayDate { 359 | flexChildren = append(flexChildren, 360 | layout.Rigid(func(gtx C) D { 361 | return layout.S.Layout(gtx, func(gtx C) D { 362 | dateWidget.Add(gtx.Ops) 363 | return dateDim 364 | }) 365 | }), 366 | ) 367 | } 368 | 369 | return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx, flexChildren...) 370 | } 371 | 372 | func (r ReplyStyle) layoutContents(gtx layout.Context) layout.Dimensions { 373 | if !r.CollapseMetadata { 374 | return layout.Flex{Axis: layout.Vertical}.Layout(gtx, 375 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 376 | return r.MetadataPadding.Layout(gtx, r.layoutMetadata) 377 | }), 378 | layout.Rigid(func(gtx layout.Context) layout.Dimensions { 379 | return r.layoutContent(gtx) 380 | }), 381 | ) 382 | } 383 | return layout.Flex{Spacing: layout.SpaceBetween}.Layout(gtx, 384 | layout.Flexed(1, func(gtx layout.Context) layout.Dimensions { 385 | return r.layoutContent(gtx) 386 | }), 387 | layout.Rigid(r.DateStyle.Layout), 388 | ) 389 | } 390 | 391 | func (r ReplyStyle) layoutContent(gtx layout.Context) layout.Dimensions { 392 | for _, c := range r.Content.Styles { 393 | c.Color.A = r.finalConfig.TextColor.A 394 | } 395 | return r.Content.Layout(gtx) 396 | } 397 | 398 | // ForestRefStyle configures the presentation of a reference to a forest 399 | // node that has both a name and an ID. 400 | type ForestRefStyle struct { 401 | NameStyle, SuffixStyle, ActivityIndicatorStyle material.LabelStyle 402 | } 403 | 404 | // ForestRef constructs a ForestRefStyle for the node with the provided info. 405 | func ForestRef(theme *material.Theme, name string, id *fields.QualifiedHash) ForestRefStyle { 406 | suffix := id.Blob 407 | suffix = suffix[len(suffix)-2:] 408 | a := ForestRefStyle{ 409 | NameStyle: material.Body2(theme, name), 410 | SuffixStyle: material.Body2(theme, "#"+hex.EncodeToString(suffix)), 411 | } 412 | a.NameStyle.Font.Weight = font.Bold 413 | a.NameStyle.MaxLines = 1 414 | a.SuffixStyle.Color.A = 150 415 | a.SuffixStyle.MaxLines = 1 416 | return a 417 | } 418 | 419 | // CommunityName constructs a ForestRefStyle for the provided community. 420 | func CommunityName(theme *material.Theme, communityName string, communityID *fields.QualifiedHash) ForestRefStyle { 421 | return ForestRef(theme, communityName, communityID) 422 | } 423 | 424 | // Layout renders the ForestRefStyle. 425 | func (f ForestRefStyle) Layout(gtx C) D { 426 | return layout.Flex{}.Layout(gtx, 427 | layout.Rigid(func(gtx C) D { 428 | return f.NameStyle.Layout(gtx) 429 | }), 430 | layout.Rigid(func(gtx C) D { 431 | return f.SuffixStyle.Layout(gtx) 432 | }), 433 | ) 434 | } 435 | 436 | // AuthorNameStyle configures the presentation of an Author name that can be presented with an activity indicator. 437 | type AuthorNameStyle struct { 438 | Active bool 439 | ForestRefStyle 440 | ActivityIndicatorStyle material.LabelStyle 441 | } 442 | 443 | // AuthorName constructs an AuthorNameStyle for the user with the provided info. 444 | func AuthorName(theme *Theme, authorName string, authorID *fields.QualifiedHash, active bool) AuthorNameStyle { 445 | a := AuthorNameStyle{ 446 | Active: active, 447 | ForestRefStyle: ForestRef(theme.Theme, authorName, authorID), 448 | ActivityIndicatorStyle: material.Body2(theme.Theme, "●"), 449 | } 450 | a.ActivityIndicatorStyle.Color = theme.Primary.Light.Bg 451 | a.ActivityIndicatorStyle.Font.Weight = font.Bold 452 | return a 453 | } 454 | 455 | // Layout renders the AuthorNameStyle. 456 | func (a AuthorNameStyle) Layout(gtx layout.Context) layout.Dimensions { 457 | return layout.Flex{}.Layout(gtx, 458 | layout.Rigid(func(gtx C) D { 459 | return a.ForestRefStyle.Layout(gtx) 460 | }), 461 | layout.Rigid(func(gtx C) D { 462 | if !a.Active { 463 | return D{} 464 | } 465 | return a.ActivityIndicatorStyle.Layout(gtx) 466 | }), 467 | ) 468 | } 469 | -------------------------------------------------------------------------------- /widget/theme/row.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "image" 5 | 6 | "gioui.org/f32" 7 | "gioui.org/io/pointer" 8 | "gioui.org/layout" 9 | "gioui.org/op" 10 | "gioui.org/op/clip" 11 | "gioui.org/unit" 12 | "gioui.org/x/richtext" 13 | chatlayout "git.sr.ht/~gioverse/chat/layout" 14 | "git.sr.ht/~whereswaldon/sprig/ds" 15 | sprigwidget "git.sr.ht/~whereswaldon/sprig/widget" 16 | ) 17 | 18 | // ReplyRowStyle configures the presentation of a row of chat history. 19 | type ReplyRowStyle struct { 20 | // VerticalMarginStyle separates the chat message vertically from 21 | // other messages. 22 | chatlayout.VerticalMarginStyle 23 | // MaxWidth constrains the maximum display width of a message. 24 | // ReplyStyle configures the presentation of the message. 25 | MaxWidth unit.Dp 26 | ReplyStyle 27 | *sprigwidget.Reply 28 | } 29 | 30 | var DefaultMaxWidth = unit.Dp(600) 31 | 32 | // ReplyRow configures a row with sensible defaults. 33 | func ReplyRow(th *Theme, state *sprigwidget.Reply, anim *sprigwidget.ReplyAnimationState, rd ds.ReplyData, richContent richtext.TextStyle) ReplyRowStyle { 34 | return ReplyRowStyle{ 35 | VerticalMarginStyle: chatlayout.VerticalMargin(), 36 | ReplyStyle: Reply(th, anim, rd, richContent, false), 37 | MaxWidth: DefaultMaxWidth, 38 | Reply: state, 39 | } 40 | } 41 | 42 | // Layout the row. 43 | func (r ReplyRowStyle) Layout(gtx C) D { 44 | return r.VerticalMarginStyle.Layout(gtx, func(gtx C) D { 45 | macro := op.Record(gtx.Ops) 46 | dims := layout.Inset{ 47 | Left: interpolateInset(r.ReplyAnimationState, r.ReplyAnimationState.Progress(gtx)), 48 | }.Layout(gtx, func(gtx C) D { 49 | gtx.Constraints.Max.X -= gtx.Dp(descendantInset) + gtx.Dp(defaultInset) 50 | if mw := gtx.Dp(r.MaxWidth); gtx.Constraints.Max.X > mw { 51 | gtx.Constraints.Max.X = mw 52 | gtx.Constraints.Min = gtx.Constraints.Constrain(gtx.Constraints.Min) 53 | } 54 | return layout.Stack{}.Layout(gtx, 55 | layout.Stacked(r.ReplyStyle.Layout), 56 | layout.Expanded(r.Reply.Polyclick.Layout), 57 | ) 58 | }) 59 | call := macro.Stop() 60 | 61 | defer pointer.PassOp{}.Push(gtx.Ops).Pop() 62 | rect := image.Rectangle{ 63 | Max: image.Point{ 64 | X: gtx.Constraints.Max.X, 65 | Y: dims.Size.Y, 66 | }, 67 | } 68 | defer clip.Rect(rect).Push(gtx.Ops).Pop() 69 | r.Reply.Layout(gtx, dims.Size.X) 70 | 71 | offset := r.Reply.DragOffset() 72 | op.Offset(f32.Pt(offset, 0).Round()).Add(gtx.Ops) 73 | call.Add(gtx.Ops) 74 | 75 | return dims 76 | }) 77 | } 78 | -------------------------------------------------------------------------------- /widget/theme/text-form.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "image/color" 5 | 6 | "gioui.org/layout" 7 | "gioui.org/unit" 8 | "gioui.org/widget/material" 9 | "git.sr.ht/~whereswaldon/sprig/icons" 10 | "git.sr.ht/~whereswaldon/sprig/widget" 11 | ) 12 | 13 | type TextFormStyle struct { 14 | State *widget.TextForm 15 | // internal widget separation distance 16 | layout.Inset 17 | PasteButton material.IconButtonStyle 18 | SubmitButton material.ButtonStyle 19 | EditorHint string 20 | EditorBackground color.NRGBA 21 | *Theme 22 | } 23 | 24 | func TextForm(th *Theme, state *widget.TextForm, submitText, formHint string) TextFormStyle { 25 | t := TextFormStyle{ 26 | State: state, 27 | Inset: layout.UniformInset(unit.Dp(8)), 28 | PasteButton: material.IconButton(th.Theme, &state.PasteButton, icons.PasteIcon, "Paste"), 29 | SubmitButton: material.Button(th.Theme, &state.SubmitButton, submitText), 30 | EditorHint: formHint, 31 | EditorBackground: th.Background.Light.Bg, 32 | Theme: th, 33 | } 34 | t.PasteButton.Inset = layout.UniformInset(unit.Dp(4)) 35 | return t 36 | } 37 | 38 | func (t TextFormStyle) Layout(gtx layout.Context) layout.Dimensions { 39 | t.State.Layout(gtx) 40 | return layout.Flex{ 41 | Alignment: layout.Middle, 42 | }.Layout(gtx, 43 | layout.Rigid(func(gtx C) D { 44 | return layout.Inset{ 45 | Right: t.Inset.Right, 46 | }.Layout(gtx, t.PasteButton.Layout) 47 | }), 48 | layout.Flexed(1, func(gtx C) D { 49 | return layout.Stack{}.Layout(gtx, 50 | layout.Expanded(func(gtx C) D { 51 | return Rect{ 52 | Color: t.EditorBackground, 53 | Size: layout.FPt(gtx.Constraints.Min), 54 | Radii: float32(gtx.Dp(unit.Dp(4))), 55 | }.Layout(gtx) 56 | }), 57 | layout.Stacked(func(gtx C) D { 58 | gtx.Constraints.Min.X = gtx.Constraints.Max.X 59 | return t.Inset.Layout(gtx, func(gtx C) D { 60 | return t.State.TextField.Layout(gtx, t.Theme.Theme, t.EditorHint) 61 | }) 62 | }), 63 | ) 64 | }), 65 | layout.Rigid(func(gtx C) D { 66 | return layout.Inset{ 67 | Left: t.Inset.Left, 68 | }.Layout(gtx, t.SubmitButton.Layout) 69 | }), 70 | ) 71 | } 72 | -------------------------------------------------------------------------------- /widget/theme/theme.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | _ "embed" 5 | "image/color" 6 | 7 | "gioui.org/font" 8 | "gioui.org/font/gofont" 9 | "gioui.org/font/opentype" 10 | "gioui.org/text" 11 | "gioui.org/widget/material" 12 | ) 13 | 14 | // PairFor wraps the provided theme color in a Color type with an automatically 15 | // populated Text color. The Text field value is chosen based on the luminance 16 | // of the provided color. 17 | func PairFor(bg color.NRGBA) ContrastPair { 18 | col := ContrastPair{ 19 | Bg: bg, 20 | } 21 | lum := grayscaleLuminance(bg) 22 | if lum < 150 { 23 | col.Fg = white 24 | } else { 25 | col.Fg = black 26 | } 27 | return col 28 | } 29 | 30 | func grayscaleLuminance(c color.NRGBA) uint8 { 31 | return uint8(float32(c.R)*.3 + float32(c.G)*.59 + float32(c.B)*.11) 32 | } 33 | 34 | var ( 35 | teal = color.NRGBA{R: 0x44, G: 0xa8, B: 0xad, A: 255} 36 | brightTeal = color.NRGBA{R: 0x79, G: 0xda, B: 0xdf, A: 255} 37 | darkTeal = color.NRGBA{R: 0x00, G: 0x79, B: 0x7e, A: 255} 38 | green = color.NRGBA{R: 0x45, G: 0xae, B: 0x7f, A: 255} 39 | brightGreen = color.NRGBA{R: 0x79, G: 0xe0, B: 0xae, A: 255} 40 | darkGreen = color.NRGBA{R: 0x00, G: 0x7e, B: 0x52, A: 255} 41 | gold = color.NRGBA{R: 255, G: 214, B: 79, A: 255} 42 | lightGold = color.NRGBA{R: 255, G: 255, B: 129, A: 255} 43 | darkGold = color.NRGBA{R: 200, G: 165, B: 21, A: 255} 44 | white = color.NRGBA{R: 255, G: 255, B: 255, A: 255} 45 | lightGray = color.NRGBA{R: 225, G: 225, B: 225, A: 255} 46 | gray = color.NRGBA{R: 200, G: 200, B: 200, A: 255} 47 | darkGray = color.NRGBA{R: 100, G: 100, B: 100, A: 255} 48 | veryDarkGray = color.NRGBA{R: 50, G: 50, B: 50, A: 255} 49 | black = color.NRGBA{A: 255} 50 | 51 | purple1 = color.NRGBA{R: 69, G: 56, B: 127, A: 255} 52 | lightPurple1 = color.NRGBA{R: 121, G: 121, B: 174, A: 255} 53 | darkPurple1 = color.NRGBA{R: 99, G: 41, B: 115, A: 255} 54 | purple2 = color.NRGBA{R: 127, G: 96, B: 183, A: 255} 55 | lightPurple2 = color.NRGBA{R: 121, G: 150, B: 223, A: 255} 56 | darkPurple2 = color.NRGBA{R: 101, G: 89, B: 223, A: 255} 57 | dmBackground = color.NRGBA{R: 12, G: 12, B: 15, A: 255} 58 | dmDarkBackground = black 59 | dmLightBackground = color.NRGBA{R: 27, G: 22, B: 33, A: 255} 60 | dmText = color.NRGBA{R: 194, G: 196, B: 199, A: 255} 61 | ) 62 | 63 | //go:embed fonts/static/NotoEmoji-Regular.ttf 64 | var emojiTTF []byte 65 | 66 | var emoji text.FontFace = func() text.FontFace { 67 | face, _ := opentype.Parse(emojiTTF) 68 | return text.FontFace{ 69 | Font: font.Font{Typeface: "emoji"}, 70 | Face: face, 71 | } 72 | }() 73 | 74 | func New() *Theme { 75 | collection := gofont.Collection() 76 | collection = append(collection, emoji) 77 | gioTheme := material.NewTheme() 78 | gioTheme.Shaper = text.NewShaper(text.WithCollection(collection)) 79 | var t Theme 80 | t.Theme = gioTheme 81 | t.Primary = Swatch{ 82 | Default: PairFor(green), 83 | Light: PairFor(brightGreen), 84 | Dark: PairFor(darkGreen), 85 | } 86 | t.Secondary = Swatch{ 87 | Default: PairFor(teal), 88 | Light: PairFor(brightTeal), 89 | Dark: PairFor(darkTeal), 90 | } 91 | t.Background = Swatch{ 92 | Default: PairFor(lightGray), 93 | Light: PairFor(white), 94 | Dark: PairFor(gray), 95 | } 96 | t.Theme.Palette.ContrastBg = t.Primary.Default.Bg 97 | t.Theme.Palette.ContrastFg = t.Primary.Default.Fg 98 | t.Ancestors = &t.Secondary.Default.Bg 99 | t.Descendants = &t.Secondary.Default.Bg 100 | t.Selected = &t.Secondary.Light.Bg 101 | t.Unselected = &t.Background.Light.Bg 102 | t.Siblings = t.Unselected 103 | 104 | t.FadeAlpha = 128 105 | 106 | return &t 107 | } 108 | 109 | func (t *Theme) ToDark() { 110 | t.Background.Dark = PairFor(darkGray) 111 | t.Background.Default = PairFor(veryDarkGray) 112 | t.Background.Light = PairFor(black) 113 | t.Primary.Default = PairFor(purple1) 114 | t.Primary.Light = PairFor(lightPurple1) 115 | t.Primary.Dark = PairFor(darkPurple1) 116 | t.Secondary.Default = PairFor(purple2) 117 | t.Secondary.Light = PairFor(lightPurple2) 118 | t.Secondary.Dark = PairFor(darkPurple2) 119 | 120 | t.Background.Default = PairFor(dmBackground) 121 | t.Background.Light = PairFor(dmLightBackground) 122 | t.Background.Dark = PairFor(dmDarkBackground) 123 | 124 | // apply to theme 125 | t.Theme.Palette.Fg, t.Theme.Palette.Bg = t.Theme.Palette.Bg, t.Theme.Palette.Fg 126 | t.Theme.Palette = ApplyAsContrast(t.Theme.Palette, t.Primary.Default) 127 | } 128 | 129 | type ContrastPair struct { 130 | Fg, Bg color.NRGBA 131 | } 132 | 133 | func ApplyAsContrast(p material.Palette, pair ContrastPair) material.Palette { 134 | p.ContrastBg = pair.Bg 135 | p.ContrastFg = pair.Fg 136 | return p 137 | } 138 | 139 | func ApplyAsNormal(p material.Palette, pair ContrastPair) material.Palette { 140 | p.Bg = pair.Bg 141 | p.Fg = pair.Fg 142 | return p 143 | } 144 | 145 | type Swatch struct { 146 | Light, Dark, Default ContrastPair 147 | } 148 | 149 | type Theme struct { 150 | *material.Theme 151 | Primary Swatch 152 | Secondary Swatch 153 | Background Swatch 154 | 155 | FadeAlpha uint8 156 | 157 | Ancestors, Descendants, Selected, Siblings, Unselected *color.NRGBA 158 | } 159 | 160 | func (t *Theme) ApplyAlpha(c color.NRGBA) color.NRGBA { 161 | c.A = t.FadeAlpha 162 | return c 163 | } 164 | -------------------------------------------------------------------------------- /widget/theme/utils.go: -------------------------------------------------------------------------------- 1 | package theme 2 | 3 | import ( 4 | "image" 5 | "image/color" 6 | 7 | "gioui.org/f32" 8 | "gioui.org/layout" 9 | "gioui.org/op/clip" 10 | "gioui.org/op/paint" 11 | ) 12 | 13 | // Rect creates a rectangle of the provided background color with 14 | // Dimensions specified by size and a corner radius (on all corners) 15 | // specified by radii. 16 | type Rect struct { 17 | Color color.NRGBA 18 | Size f32.Point 19 | Radii float32 20 | } 21 | 22 | // Layout renders the Rect into the provided context 23 | func (r Rect) Layout(gtx C) D { 24 | paint.FillShape(gtx.Ops, r.Color, clip.UniformRRect(image.Rectangle{Max: r.Size.Round()}, int(r.Radii)).Op(gtx.Ops)) 25 | return layout.Dimensions{Size: image.Pt(int(r.Size.X), int(r.Size.Y))} 26 | } 27 | 28 | // LayoutUnder ignores the Size field and lays the rectangle out beneath the 29 | // provided widget, matching its dimensions. 30 | func (r Rect) LayoutUnder(gtx C, w layout.Widget) D { 31 | return layout.Stack{}.Layout(gtx, 32 | layout.Expanded(func(gtx C) D { 33 | r.Size = layout.FPt(gtx.Constraints.Min) 34 | return r.Layout(gtx) 35 | }), 36 | layout.Stacked(w), 37 | ) 38 | } 39 | 40 | // LayoutUnder ignores the Size field and lays the rectangle out beneath the 41 | // provided widget, matching its dimensions. 42 | func (r Rect) LayoutOver(gtx C, w layout.Widget) D { 43 | return layout.Stack{}.Layout(gtx, 44 | layout.Stacked(w), 45 | layout.Expanded(func(gtx C) D { 46 | r.Size = layout.FPt(gtx.Constraints.Min) 47 | return r.Layout(gtx) 48 | }), 49 | ) 50 | } 51 | --------------------------------------------------------------------------------