├── sprig.app.template
└── Contents
│ ├── PkgInfo
│ ├── Info.plist
│ └── _CodeSignature
│ └── CodeResources
├── appicon.png
├── img
└── screenshot.png
├── appicon-release.png
├── platform
├── mobile.go
└── desktop.go
├── .gitignore
├── version.go
├── widget
├── theme
│ ├── fonts
│ │ └── static
│ │ │ └── NotoEmoji-Regular.ttf
│ ├── icon-button.go
│ ├── utils.go
│ ├── text-form.go
│ ├── row.go
│ ├── composer.go
│ ├── theme.go
│ ├── message-list.go
│ └── reply.go
├── text-form.go
├── polyclick.go
├── reply.go
├── composer.go
└── message-list.go
├── desktop-assets
└── sprig.desktop
├── tools.go
├── platform_other.go
├── intent.go
├── reply-view_mobile.go
├── reply-view_desktop.go
├── platform_android.go
├── view.go
├── install-linux.sh
├── core
├── haptic-service.go
├── theme-service.go
├── status-service.go
├── banner-service.go
├── arbor-service.go
├── app.go
├── notification-service.go
├── sprout-service.go
└── settings-service.go
├── anim
└── anim.go
├── .github
└── workflows
│ └── issue-replication.yml
├── chat.arbor.Client.Sprig.yml
├── README.md
├── scripts
└── macos-poll-and-build.sh
├── go.mod
├── connect-form-view.go
├── Makefile
├── icons
└── icons.go
├── subscription-setup-form.go
├── identity-form.go
├── .builds
└── debian.yml
├── consent-view.go
├── magefile.go
├── ds
├── reply-list.go
├── trackers.go
└── community-list.go
├── main.go
├── theme-editor.go
├── subscription-view.go
├── settings-view.go
├── view-manager.go
└── LICENSE.txt
/sprig.app.template/Contents/PkgInfo:
--------------------------------------------------------------------------------
1 | APPL????
--------------------------------------------------------------------------------
/appicon.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arborchat/sprig/HEAD/appicon.png
--------------------------------------------------------------------------------
/img/screenshot.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arborchat/sprig/HEAD/img/screenshot.png
--------------------------------------------------------------------------------
/appicon-release.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arborchat/sprig/HEAD/appicon-release.png
--------------------------------------------------------------------------------
/platform/mobile.go:
--------------------------------------------------------------------------------
1 | //+build ios android
2 |
3 | package platform
4 |
5 | const Mobile = true
6 |
--------------------------------------------------------------------------------
/platform/desktop.go:
--------------------------------------------------------------------------------
1 | //+build !ios,!android
2 |
3 | package platform
4 |
5 | const Mobile = false
6 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | sprig.apk
2 | sprig.test
3 | sprig
4 | *.apk
5 | pakbuild/
6 | .flatpak-builder/
7 | vendor/
8 |
--------------------------------------------------------------------------------
/version.go:
--------------------------------------------------------------------------------
1 | package main
2 |
3 | var (
4 | Version = "git"
5 | VersionString = "Build: " + Version
6 | )
7 |
--------------------------------------------------------------------------------
/widget/theme/fonts/static/NotoEmoji-Regular.ttf:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/arborchat/sprig/HEAD/widget/theme/fonts/static/NotoEmoji-Regular.ttf
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | ## sprig
2 |
3 | Sprig is the [Arbor](https://arbor.chat) reference chat client.
4 |
5 | 
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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------
/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 |
--------------------------------------------------------------------------------