~f4814n/frost

9d4efdf191f8f3c6297ffbacda5667fcc846c074 — Fabian Geiselhart 1 year, 1 day ago 15d2d70
Import matrix library and create nix flake

2

3

4
M .gitignore => .gitignore +1 -1
@@ 1,2 1,2 @@
frost
shell.nix
result

M README.md => README.md +1 -1
@@ 3,7 3,7 @@
[![builds.sr.ht status](https://builds.sr.ht/~f4814n/frost.svg)](https://builds.sr.ht/~f4814n/frost?)

Frost is a (pre-alpha) matrix client written in Golang using the [gio](https://gioui.org)
library and my [matrix library](https://git.sr.ht/~f4814n/matrix).
library and my [matrix library](https://git.sr.ht/~f4814n/frost/matrix).


There is no bugtracker or mailing list because I do not think anyone is interested

A flake.lock => flake.lock +57 -0
@@ 0,0 1,57 @@
{
  "nodes": {
    "flake-compat": {
      "flake": false,
      "locked": {
        "lastModified": 1599123690,
        "narHash": "sha256-SaPcfjQLubbQSegD8mkWmuBWu2Zf0vuhSBrulmIculo=",
        "owner": "edolstra",
        "repo": "flake-compat",
        "rev": "cecfd08d13ddef8a79f277e67b8084bd9afa1586",
        "type": "github"
      },
      "original": {
        "owner": "edolstra",
        "repo": "flake-compat",
        "type": "github"
      }
    },
    "flake-utils": {
      "locked": {
        "lastModified": 1596290160,
        "narHash": "sha256-QNXPeCiLKfLH6G6TtyuxZ14Zm+ZMxBaQ0Klccq+2RfM=",
        "owner": "numtide",
        "repo": "flake-utils",
        "rev": "0c686c77c4ac6f7add3262107685693ada8ae3b0",
        "type": "github"
      },
      "original": {
        "owner": "numtide",
        "repo": "flake-utils",
        "type": "github"
      }
    },
    "nixpkgs": {
      "locked": {
        "lastModified": 1599476815,
        "narHash": "sha256-U+D5lLRB0TalwExyHT4ubWR5hXgYLhEBytKpFeu1SRQ=",
        "path": "/nix/store/qlvaa0sqi51n4by2f49wsdia1x4dgj3k-source",
        "rev": "4bd1938e03e1caa49a6da1ec8cff802348458f05",
        "type": "path"
      },
      "original": {
        "id": "nixpkgs",
        "type": "indirect"
      }
    },
    "root": {
      "inputs": {
        "flake-compat": "flake-compat",
        "flake-utils": "flake-utils",
        "nixpkgs": "nixpkgs"
      }
    }
  },
  "root": "root",
  "version": 7
}

A flake.nix => flake.nix +61 -0
@@ 0,0 1,61 @@
{
  description = "WIP Matrix client";

  inputs = {
    nixpkgs.url = "nixpkgs";
    flake-utils.url = "github:numtide/flake-utils";
    flake-compat = {
      url = "github:edolstra/flake-compat";
      flake = false;
    };
  };

  outputs = { self, nixpkgs, flake-utils, ... }:
    let
      devShell = with import nixpkgs { system = "x86_64-linux"; }; {
        devShell."x86_64-linux" = mkShell {
          buildInputs = [
            nixfmt
            go
            goimports
            gopls
            golangci-lint
            delve
            pkgconfig
            x11
            wayland
            libxkbcommon
            libGL
          ];
          shellHook = ''
            export GOPATH=~/.cache/gopath
            export PATH=$(go env GOPATH)/bin:$PATH
          '';
        };
      };

      default = {
        name = "frost";
        src = self;
        vendorSha256 = "GLIJrkVaXy8Sn2ARpDNQ16x+xe00ISHLc9Q7zoU5iaU=";
      };

      linuxPackages = flake-utils.lib.eachSystem [
        "x86_64-linux"
        "aarch64-linux"
        "i686-linux"
      ] (system:
        with import nixpkgs { system = system; };
        let
          frost = buildGoModule (default // {
            nativeBuildInputs = [ pkgconfig ];
            buildInputs = [ libGL wayland x11 libxkbcommon ];
          });
        in {
          packages.frost = frost;
          apps.frost = flake-utils.lib.mkApp { drv = frost; };
          defaultPackage = frost;
          defaultApp = flake-utils.lib.mkApp { drv = frost; };
        });
    in devShell // linuxPackages;
}

M go.mod => go.mod +3 -4
@@ 1,10 1,9 @@
module git.sr.ht/~f4814n/frost

go 1.14
go 1.15

require (
	gioui.org v0.0.0-20200726090339-83673ecb203f
	git.sr.ht/~f4814n/matrix v0.0.0-20200611230945-c9c010f4101b
	gioui.org v0.0.0-20200917085049-ef7b3e75f4dc
	github.com/sirupsen/logrus v1.6.0
	golang.org/x/exp v0.0.0-20200513190911-00229845015e
	golang.org/x/exp v0.0.0-20200917184745-18d7dbdd5567
)

M go.sum => go.sum +10 -13
@@ 1,25 1,26 @@
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
gioui.org v0.0.0-20200726090339-83673ecb203f h1:09YCGWJqqcRI/X3HEODsA/val03uLETNjTmQ/dUBSAY=
gioui.org v0.0.0-20200726090339-83673ecb203f/go.mod h1:jiUwifN9cRl/zmco43aAqh0aV+s9GbhG13KcD+gEpkU=
git.sr.ht/~f4814n/matrix v0.0.0-20200611230945-c9c010f4101b h1:pKo33A8KyC0ywSOVJ53ki5IN59VZAXr7vS9TgIKliJw=
git.sr.ht/~f4814n/matrix v0.0.0-20200611230945-c9c010f4101b/go.mod h1:UGkYwUsyC2TIesV1ex6E6Jt0CUT8RIpfdxpLLW83qxY=
gioui.org v0.0.0-20200917085049-ef7b3e75f4dc h1:H4dzzKcjkPGfEp8ANkmNOwG42bwd4p22M/6mFXnb6Q0=
gioui.org v0.0.0-20200917085049-ef7b3e75f4dc/go.mod h1:Y+uS7hHMvku1Q+ooaoq6fYD5B2LGoT8JtFgvmYmRzTw=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20200222043503-6f7a984d4dc4/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/golang/mock v1.4.3/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/konsorten/go-windows-terminal-sequences v1.0.3 h1:CE8S1cTafDpPvMhIxNJKvHsGVBgn1xWYf1NbHQhywc8=
github.com/konsorten/go-windows-terminal-sequences v1.0.3/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.6.0 h1:UBcNElsrwanuuMsnGSlYmtmgbb23qDR5dG+6X6Oo89I=
github.com/sirupsen/logrus v1.6.0/go.mod h1:7uNnSEd1DgxDLC74fIahvMZmmYsHGZGEOFrfsX/uA88=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20191002040644-a1355ae1e2c3/go.mod h1:NOZ3BPKG0ec/BKJQgnvsSFpcKLM5xXVWnvZS97DWHgE=
golang.org/x/exp v0.0.0-20200513190911-00229845015e h1:rMqLP+9XLy+LdbCXHjJHAmTfXCr93W7oruWA6Hq1Alc=
golang.org/x/exp v0.0.0-20200513190911-00229845015e/go.mod h1:4M0jN8W1tt0AVLNr8HDosyJCDCDuyL9N9+3m7wDWgKw=
golang.org/x/exp v0.0.0-20200917184745-18d7dbdd5567 h1:HfuaahSgKLEovs8K+Sm4QyZnRoDdGeGx+PrVlOVRtIk=
golang.org/x/exp v0.0.0-20200917184745-18d7dbdd5567/go.mod h1:1phAWC201xIgDyaFpmDeZkgf70Q4Pd/CNqfRtVPtxNw=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/image v0.0.0-20200618115811-c13761719519 h1:1e2ufUJNM3lCHEY5jIgac/7UTjd6cgJNdatjPdFWf34=


@@ 27,8 28,7 @@ golang.org/x/image v0.0.0-20200618115811-c13761719519/go.mod h1:FeLwcggjj3mMvU+o
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.1.1-0.20191107180719-034126e5016b/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/mod v0.3.1-0.20200828183125-ce943fd02449/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=


@@ 39,13 39,10 @@ golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7w
golang.org/x/sys v0.0.0-20191001151750-bb3f8db39f24/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9 h1:1/DFK4b7JH8DmkqhUk48onnSfrPzImPoVxuomtbT2nk=
golang.org/x/sys v0.0.0-20200124204421-9fbb57f87de9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0 h1:g61tztE5qeGQ89tm6NTjjM9VPIm088od1l6aSorWRWg=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190927191325-030b2cf1153e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

M list_page.go => list_page.go +1 -1
@@ 15,7 15,7 @@ import (
	"gioui.org/unit"
	"gioui.org/widget"
	"gioui.org/widget/material"
	"git.sr.ht/~f4814n/matrix"
	"git.sr.ht/~f4814n/frost/matrix"
)

type ListPage struct {

M main.go => main.go +2 -2
@@ 10,8 10,8 @@ import (
	"gioui.org/op"
	"gioui.org/unit"
	"gioui.org/widget/material"
	"git.sr.ht/~f4814n/matrix"
	memorybackend "git.sr.ht/~f4814n/matrix/backend/memory"
	"git.sr.ht/~f4814n/frost/matrix"
	memorybackend "git.sr.ht/~f4814n/frost/matrix/backend/memory"
	log "github.com/sirupsen/logrus"
)


A matrix/.build.yml => matrix/.build.yml +20 -0
@@ 0,0 1,20 @@
image: alpine/edge
packages:
  - go
sources:
  - https://git.sr.ht/~f4814n/matrix
secrets:
   - 1d0e497b-270b-4a32-b9ff-f7886599c4e6
tasks:
  - build: |
      cd matrix
      go build ./...

  - test: |
      cd matrix
      go test ./...

  - annotate: |
      cd matrix
      go run git.sr.ht/~sircmpwn/annotatego -Tv ./... > annotations.json
      ../upload-annotations annotations.json f4814n matrix

A matrix/.golangci.toml => matrix/.golangci.toml +2 -0
@@ 0,0 1,2 @@
[linters]
enable = [ "gocyclo", "gofmt", "goimports", "golint", "gosec", "misspell", "stylecheck" ]

A matrix/api/client.go => matrix/api/client.go +92 -0
@@ 0,0 1,92 @@
package api

import (
	"bytes"
	"encoding/json"
	"net/http"
	"net/url"
)

const (
	prefix = "/_matrix/client/r0/"
)

type Auth func(*http.Request)

type Client struct {
	homeserver string
	cli        *http.Client
}

func NewClient(client *http.Client, homeserver string) *Client {
	return &Client{
		homeserver: homeserver,
		cli:        client,
	}
}

type MatrixError struct {
	Code string `json:"errcode"`
	Err  string `json:"error"`
}

func (m MatrixError) Error() string {
	return m.Code + " " + m.Err
}

func (cli *Client) makeRequest(method string, path *url.URL, auth Auth, body interface{}, resp interface{}) error {
	var buf bytes.Buffer
	err := json.NewEncoder(&buf).Encode(body)
	if err != nil {
		return err
	}

	req, err := http.NewRequest(method, path.String(), &buf)
	if err != nil {
		return err
	}

	if auth != nil {
		auth(req)
	}

	httpResp, err := cli.cli.Do(req)
	if err != nil {
		return err
	}
	defer httpResp.Body.Close()

	dec := json.NewDecoder(httpResp.Body)
	if httpResp.StatusCode != 200 {
		var merr MatrixError
		err := dec.Decode(&merr)
		if err != nil {
			return err
		}
		return merr
	}

	if resp == nil {
		return nil
	}

	return dec.Decode(resp)
}

func (cli *Client) makeURL(path string) *url.URL {
	url, err := url.Parse("https://" + cli.homeserver + prefix + path)
	if err != nil {
		panic(err)
	}

	return url
}

func (cli *Client) makeURLRaw(path string) *url.URL {
	url, err := url.Parse("https://" + cli.homeserver + path)
	if err != nil {
		panic(err)
	}

	return url
}

A matrix/api/endpoints.go => matrix/api/endpoints.go +346 -0
@@ 0,0 1,346 @@
package api

import (
	"net/url"
	"strconv"
)

// 2.1
type VersionsResp struct {
	Versions         []string        `json:"versions"`
	UnstableFeatures map[string]bool `json:"unstable_features,omitempty"`
}

func (cli *Client) Versions() (VersionsResp, error) {
	var resp VersionsResp
	err := cli.makeRequest("GET", cli.makeURLRaw("/_matrix/client/versions"), nil, nil, &resp)
	return resp, err
}

// 5.3.6
type Identifier map[string]interface{}

func NewUserIdentifer(username string) Identifier {
	return map[string]interface{}{
		"type": "m.id.user",
		"user": username,
	}
}

func New3rdpartyIdentifer(medium, address string) Identifier {
	return map[string]interface{}{
		"type":    "m.id.thirdparty",
		"medium":  medium,
		"address": address,
	}
}

func NewPhoneIdentifer(country, phone string) Identifier {
	return map[string]interface{}{
		"type":    "m.id.phone",
		"country": country,
		"phone":   phone,
	}
}

// 5.4.2
type LoginBody struct {
	Type                     string     `json:"type"`
	Identifier               Identifier `json:"identifier,omitempty"`
	User                     string     `json:"user,omitempty"`
	Medium                   string     `json:"medium,omitempty"`
	Address                  string     `json:"address,omitempty"`
	Password                 string     `json:"password,omitempty"`
	Token                    string     `json:"token,omitempty"`
	DeviceID                 string     `json:"device_id"`
	InitialDeviceDisplayName string     `json:"initial_device_display_name,omitempty"`
}

type LoginResp struct {
	UserID      string `json:"user_id"`
	AccessToken string `json:"access_token"`
	HomeServer  string `json:"home_server"`
	DeviceID    string `json:"device_id"`
	WellKnown   struct {
		Homeserver struct {
			BaseURL string `json:"base_url"`
		} `json:"m.homeserver"`
		IdentityServer struct {
			BaseURL string `json:"base_url"`
		} `json:"m.identity_server"`
	} `json:"well_known"`
}

func (cli *Client) Login(body LoginBody) (LoginResp, error) {
	var resp LoginResp
	err := cli.makeRequest("POST", cli.makeURL("login"), nil, body, &resp)
	return resp, err
}

// 5.4.3
func (cli *Client) Logout(auth Auth) error {
	return cli.makeRequest("POST", cli.makeURL("logout"), auth, nil, nil)
}

// 5.4.4
func (cli *Client) LogoutAll(auth Auth) error {
	return cli.makeRequest("POST", cli.makeURL("logoutAll"), auth, nil, nil)
}

// 5.7.1

type WhoamiResp struct {
	UserID string `json:"user_Id"`
}

func (cli *Client) Whoami(auth Auth) (WhoamiResp, error) {
	var resp WhoamiResp
	err := cli.makeRequest("GET", cli.makeURL("account/whoami"), auth, nil, &resp)
	return resp, err
}

// 9.4.1
type SyncResp struct {
	NextBatch              string              `json:"next_batch"`
	Rooms                  SyncRespRooms       `json:"rooms,omitempty"`
	Presence               SyncRespPresence    `json:"presence,omitempty"`
	AccountData            SyncRespAccountData `json:"account_data,omitempty"`
	ToDevice               SyncRespToDevice    `json:"to_device,omitempty"`
	DeviceLists            SyncRespDeviceLists `json:"device_lists,omitempty"`
	DeviceOneTimeKeysCount map[string]int64    `json:"device_one_time_keys_count,omitempty"`
}

type SyncRespRooms struct {
	Join   map[string]SyncRespRoomsJoin   `json:"join,omitempty"`
	Invite map[string]SyncRespRoomsInvite `json:"invite,omitempty"`
	Leave  map[string]SyncRespRoomsLeave  `json:"leave,omitempty"`
}

type SyncRespRoomsJoin struct {
	Summary             SyncRespRoomSummary              `json:"summary,omitempty"`
	State               SyncRespState                    `json:"state,omitempty"`
	Timeline            SyncRespTimeline                 `json:"timeline,omitempty"`
	Ephemeral           SyncRespEphemeral                `json:"ephemeral,omitempty"`
	AccountData         SyncRespAccountData              `json:"account_data,omitempty"`
	UnreadNotifications SyncRespUnreadNotificationCounts `json:"unread_notifications,omitempty"`
}

type SyncRespRoomSummary struct {
	Heroes              []string `json:"m.heroes,omitempty"`
	JoinedMembersCount  int      `json:"m.joined_members_count,omitempty"`
	InvitedMembersCOund int      `json:"m.invited_members_count,omitempty"`
}

type SyncRespEphemeral struct {
	Events []SyncRespEvent `json:"events,omitempty"`
}

type SyncRespUnreadNotificationCounts struct {
	HighlightCount    int `json:"hightlight_count,omitempty"`
	NotificationCount int `json:"notification_count,omitempty"`
}

type SyncRespRoomsInvite struct {
	InviteState SyncRespInviteState `json:"invite_state,omitempty"`
}

type SyncRespInviteState struct {
	Events []SyncRespStrippedState `json:"events,omitempty"`
}

type SyncRespStrippedState struct {
	Content  map[string]interface{} `json:"content,omitempty"`
	StateKey string                 `json:"state_key,omitempty"`
	Type     string                 `json:"type,omitempty"`
	Sender   string                 `json:"sender,omitempty"`
}

type SyncRespRoomsLeave struct {
	State       SyncRespState       `json:"state,omitempty"`
	Timeline    SyncRespTimeline    `json:"timeline,omitempty"`
	AccountData SyncRespAccountData `json:"account_data,omitempty"`
}

type SyncRespState struct {
	Events []SyncRespRoomEvent `json:"events,omitempty"`
}

type SyncRespRoomEvent struct {
	Content        map[string]interface{} `json:"content"`
	Type           string                 `json:"type"`
	EventID        string                 `json:"event_id"`
	Sender         string                 `json:"sender"`
	OriginServerTS int64                  `json:"origin_server_ts"`
	Unsigned       SyncRespUnsignedData   `json:"unsigned_data,omitempty"`
	PrevContent    map[string]interface{} `json:"prev_content,omitempty"`
	StateKey       *string                `json:"state_key,omitempty"`
}

type SyncRespTimeline struct {
	Events    []SyncRespRoomEvent `json:"events,omitempty"`
	Limited   bool                `json:"limited,omitempty"`
	PrevBatch string              `json:"prev_batch,omitempty"`
}

type SyncRespUnsignedData struct {
	Age             int64         `json:"age,omitempty"`
	RedactedBecause SyncRespEvent `json:"redacted_because,omitempty"`
	TransactionID   string        `json:"transaction_id,omitempty"`
}

type SyncRespPresence struct {
	Events []SyncRespEvent `json:"events,omitempty"`
}

type SyncRespAccountData struct {
	Events []SyncRespEvent `json:"events,omitempty"`
}

type SyncRespEvent struct {
	Content map[string]interface{} `json:"content,omitempty"`
	Type    string                 `json:"type,omitempty"`
}

func (cli *Client) Sync(auth Auth, filter, since string, fullState bool, setPresence string, timeout int64) (SyncResp, error) {
	url := cli.makeURL("sync")
	q := url.Query()
	if since != "" {
		q.Set("since", since)
	}
	q.Set("fullState", strconv.FormatBool(fullState))
	q.Set("setPresence", setPresence)
	q.Set("timeout", strconv.FormatInt(timeout, 10))
	url.RawQuery = q.Encode()

	var resp SyncResp
	err := cli.makeRequest("GET", url, auth, nil, &resp)
	return resp, err
}

// 9.5.6

type GetRoomMessagesResp struct {
	Start string                 `json:"start,omitempty"`
	End   string                 `json:"end,omitempty"`
	Chunk []GetRoomMessagesEvent `json:"chunk,omitempty"`
	State []GetRoomMessagesEvent `json:"state,omitempty"`
}

type GetRoomMessagesEvent struct {
	Content        map[string]interface{}      `json:"content"`
	Type           string                      `json:"type"`
	EventID        string                      `json:"event_id"`
	Sender         string                      `json:"sender"`
	OriginServerTS int64                       `json:"origin_server_ts"`
	Unsigned       GetRoomMessagesUnsignedData `json:"unsigned,omitempty"`
	RoomID         string                      `json:"room_id"`
	PrevContent    map[string]interface{}      `json:"prev_content,omitempty"`
	StateKey       *string                     `json:"state_key"`
}

type GetRoomMessagesUnsignedData struct {
	Age             int64                  `json:"age,omitempty"`
	RedactedBecause map[string]interface{} `json:"redacted_because,omitempty"`
	TransactionID   string                 `json:"transaction_id,omitempty"`
}

func (cli *Client) GetRoomMessages(auth Auth, roomID string, from, to, dir string, limit int, filter string) (GetRoomMessagesResp, error) {
	url := cli.makeURL("rooms/" + roomID + "/messages")
	q := url.Query()
	q.Set("from", from)
	if to != "" {
		q.Set("to", to)
	}
	q.Set("dir", dir)
	q.Set("limit", strconv.FormatInt(int64(limit), 10))
	if filter != "" {
		q.Set("filter", filter)
	}
	url.RawQuery = q.Encode()

	var resp GetRoomMessagesResp

	err := cli.makeRequest("GET", url, auth, nil, &resp)
	return resp, err
}

// 9.6.1
type PutStateEventBody map[string]interface{}

type PutStateEventResp struct {
	EventID string `json:"event_id"`
}

func (cli *Client) PutStateEvent(auth Auth, roomID, eventType, stateKey string, body PutStateEventBody) (PutStateEventResp, error) {
	url := cli.makeURL("rooms/" + roomID + "/state/" + eventType + "/" + stateKey)
	var resp PutStateEventResp
	err := cli.makeRequest("PUT", url, auth, body, &resp)
	return resp, err
}

// 9.6.2
type PutRoomEventBody map[string]interface{}

type PutRoomEventResp struct {
	EventID string `json:"event_id"`
}

func (cli *Client) PutRoomEvent(auth Auth, roomID, eventType, txnID string, body PutRoomEventBody) (PutRoomEventResp, error) {
	url := cli.makeURL("rooms/" + roomID + "/send/" + eventType + "/" + txnID)
	var resp PutRoomEventResp
	err := cli.makeRequest("PUT", url, auth, body, &resp)
	return resp, err
}

// 10.2.2

type GetRoomAliasIDResp struct {
	RoomID  string   `json:"room_id"`
	Servers []string `json:"servers"`
}

func (cli *Client) GetRoomAliasID(alias string) (GetRoomAliasIDResp, error) {
	var resp GetRoomAliasIDResp
	err := cli.makeRequest("GET", cli.makeURL("directory/room/"+url.QueryEscape(alias)), nil, nil, &resp)
	return resp, err
}

// 10.5.1

type GetRoomVisibilityResp struct {
	Visibility string `json:"visibility"`
}

func (cli *Client) GetRoomVisibility(id string) (GetRoomVisibilityResp, error) {
	var resp GetRoomVisibilityResp
	err := cli.makeRequest("GET", cli.makeURL("directory/list/room/"+url.QueryEscape(id)), nil, nil, &resp)
	return resp, err
}

// 11.2.2

type GetUserDisplaynameResp struct {
	Displayname string `json:"displayname"`
}

func (cli *Client) GetUserDisplayname(userID string) (resp GetUserDisplaynameResp, err error) {
	err = cli.makeRequest("GET", cli.makeURL("profile/"+url.QueryEscape(userID)+"/displayname"), nil, nil, &resp)
	return
}

// 13.9.3.2
type SyncRespToDevice struct {
	Events []SyncRespToDeviceEvent `json:"events"`
}

type SyncRespToDeviceEvent struct {
	Content map[string]interface{} `json:"content"`
	Sender  string                 `json:"sender"`
	Type    string                 `json:"type"`
}

// 13.11.5.3
type SyncRespDeviceLists struct {
	Changed []string `json:"changed"`
	Left    []string `json:"left"`
}

A matrix/backend.go => matrix/backend.go +63 -0
@@ 0,0 1,63 @@
package matrix

// The Backend is responsible for storing session information
// (mxid, device ID, access token) and caching.
// A Backend is tied to exactly one session (or device in matrix
// terminology).
// A Backend implementation must be threadsafe!
type Backend interface {
	// Initialize configures the backend to store information
	// for the provided device. This should be called only
	// when a new device is created or mxid, deviceID and
	// access token have been persisted in another way.
	// Initialize panics if there is a persisted session
	// available (i.e if Open(mxid, deviceID) would return
	// nil.
	// Calling Initialize multiple times panics.
	Initialize(mxid, deviceID, accessToken string)

	// Open configures the backend to load already an already
	// persisted session. This should be called if the
	// Backend implementation is able to persist information
	// between restarts. If the provided mxid-deviceID
	// combination is not known this returns an error.
	// Calling Open multiple times panics.
	Open(mxid, deviceID string) error

	// MxID returns the MxID of the user owing the device.
	MxID() string

	// DeviceID returns the device ID of the device.
	DeviceID() string

	// AccessToken returns the access token used by the device.
	AccessToken() string

	// AccountData gets a account data event of the specified
	// type from the given room. If room is nil the event
	// is searched in the users global account data.
	// If no event is found nil is returned.
	AccountData(room *Room, type_ string) *AccountDataEvent

	// UpdateAccountData updates the chached account data.
	UpdateAccountData(AccountDataEvent)

	// RoomState gets a StateEvent from a room.
	RoomState(room Room, type_, key string) *StateEvent

	// RoomStateList gets all state events of the specified type
	// from the room.
	RoomStateList(room Room, type_ string) []StateEvent

	// UpdateRoomState updates the cached room state.
	UpdateRoomState(event StateEvent)

	// AddLatestEvents adds events freshly received from the /sync
	// endpoint and the prevBatch token used to iterate room history
	// into the cache.
	AddLatestEvents(room Room, events []Event, prevBatch string)

	// LatestEvents returns the latest Events received from the /sync
	// endpoint and the prevBatch token from the cache.
	LatestEvents(room Room) ([]Event, string)
}

A matrix/backend/memory/backend.go => matrix/backend/memory/backend.go +178 -0
@@ 0,0 1,178 @@
// Package memory contains an in-memory implementation of the Backend interface.
package memory

import (
	"errors"
	"sync"

	"git.sr.ht/~f4814n/frost/matrix"
)

type Backend struct {
	mxid, deviceID, accessToken string
	state                       map[stateKey]matrix.StateEvent
	accountData                 map[string]map[string]matrix.AccountDataEvent
	latestEvents                map[string][]matrix.Event
	prevBatch                   map[string]string

	mut sync.RWMutex
}

type stateKey struct {
	room  string
	type_ string
	key   string
}

func New() *Backend {
	return &Backend{
		state:        make(map[stateKey]matrix.StateEvent),
		latestEvents: make(map[string][]matrix.Event),
		prevBatch:    make(map[string]string),
		accountData:  make(map[string]map[string]matrix.AccountDataEvent),
	}
}

func (b *Backend) Initialize(mxid, deviceID, accessToken string) {
	b.mut.Lock()
	defer b.mut.Unlock()

	if b.mxid != "" || b.accessToken != "" || b.deviceID != "" { // Already Initialized
		panic("cannot initialize backend multiple times")
	}
	b.mxid, b.deviceID, b.accessToken = mxid, deviceID, accessToken
}

func (b *Backend) Open(string, string) error {
	b.mut.RLock()
	defer b.mut.RUnlock()

	if b.mxid != "" || b.accessToken != "" || b.deviceID != "" { // Already Initialized
		panic("cannot open backend. Already initialized")
	}
	return errors.New("this backend does not implement persistent sessions")
}

func (b *Backend) MxID() string {
	b.mut.RLock()
	defer b.mut.RUnlock()

	return b.mxid
}

func (b *Backend) DeviceID() string {
	b.mut.RLock()
	defer b.mut.RUnlock()

	return b.deviceID
}

func (b *Backend) AccessToken() string {
	b.mut.RLock()
	defer b.mut.RUnlock()

	return b.accessToken
}

func (b *Backend) AccountData(room *matrix.Room, type_ string) *matrix.AccountDataEvent {
	b.mut.RLock()
	defer b.mut.RUnlock()

	var r string
	if room != nil {
		r = room.ID
	}

	if m, ok := b.accountData[r]; ok {
		if ev, ok := m[type_]; ok {
			return &ev
		}
	}

	return nil
}

func (b *Backend) UpdateAccountData(ev matrix.AccountDataEvent) {
	b.mut.Lock()
	defer b.mut.Unlock()

	var room string
	if ev.Room() != nil {
		room = ev.Room().ID
	}

	if _, ok := b.accountData[room]; !ok {
		b.accountData[room] = make(map[string]matrix.AccountDataEvent)
	}

	b.accountData[room][ev.Type] = ev
}

func (b *Backend) RoomState(room matrix.Room, type_, key string) *matrix.StateEvent {
	k := stateKey{
		room:  room.ID,
		type_: type_,
		key:   key,
	}

	b.mut.RLock()
	defer b.mut.RUnlock()

	if e, ok := b.state[k]; ok {
		return &e
	}

	return nil
}

func (b *Backend) RoomStateList(room matrix.Room, type_ string) []matrix.StateEvent {
	var res []matrix.StateEvent

	b.mut.RLock()
	defer b.mut.RUnlock()

	for key, event := range b.state {
		if key.room == room.ID && key.type_ == type_ {
			res = append(res, event)
		}
	}

	return res
}

func (b *Backend) UpdateRoomState(event matrix.StateEvent) {
	k := stateKey{
		room:  event.Room.ID,
		type_: event.Type,
		key:   event.StateKey,
	}

	b.mut.Lock()
	defer b.mut.Unlock()

	b.state[k] = event
}

func (b *Backend) AddLatestEvents(room matrix.Room, events []matrix.Event, prevBatch string) {
	b.mut.Lock()
	defer b.mut.Unlock()

	b.latestEvents[room.ID] = events
	b.prevBatch[room.ID] = prevBatch
}

func (b *Backend) LatestEvents(room matrix.Room) ([]matrix.Event, string) {
	b.mut.RLock()
	defer b.mut.RUnlock()

	var events []matrix.Event
	if ev, ok := b.latestEvents[room.ID]; ok {
		events = ev
	}

	if batch, ok := b.prevBatch[room.ID]; ok {
		return events, batch
	}

	return events, ""
}

A matrix/backend/memory/backend_test.go => matrix/backend/memory/backend_test.go +12 -0
@@ 0,0 1,12 @@
package memory

import (
	"testing"

	"git.sr.ht/~f4814n/frost/matrix/backend"
)

func Test(t *testing.T) {
	b := New()
	backend.Test(t, b)
}

A matrix/backend/sqlite/backend.go => matrix/backend/sqlite/backend.go +1 -0
@@ 0,0 1,1 @@
package sqlite

A matrix/backend/test.go => matrix/backend/test.go +125 -0
@@ 0,0 1,125 @@
package backend

import (
	"reflect"
	"testing"

	"git.sr.ht/~f4814n/frost/matrix"
)

func Test(t *testing.T, backend matrix.Backend) {
	t.Parallel()

	t.Run("initialization", func(t *testing.T) { testInit(t, backend) })

	t.Run("state", func(t *testing.T) { testState(t, backend) })

	t.Run("latestEvents", func(t *testing.T) { testLatestEvents(t, backend) })
}

func testInit(t *testing.T, backend matrix.Backend) {
	t.Parallel()

	backend.Initialize("@test:test.org", "TESTID", "token")

	func() {
		defer func() {
			if r := recover(); r == nil {
				t.Fatal("multiple initialization did not panic")
			}
		}()
		backend.Initialize("@test:test.org", "TESTID", "token")
	}()

	if backend.MxID() != "@test:test.org" {
		t.Fatal("got wrong mxid. Initalization failed.")
	}

	if backend.DeviceID() != "TESTID" {
		t.Fatal("got wrong device id. Initialization failed.")
	}

	if backend.AccessToken() != "token" {
		t.Fatal("go wrong access token. Initialization failed")
	}
}

func testState(t *testing.T, backend matrix.Backend) {
	t.Parallel()

	room := matrix.Room{ID: "!aaaaaaaaaaaaa:f4814n.de"}

	first := matrix.StateEvent{
		Content: map[string]interface{}{
			"first": "content",
		},
		Type:     "de.f4814n.testing",
		ID:       "aaaaaaaaaaaaaaa",
		StateKey: "test",
		Room:     room,
		Sender:   matrix.Member{User: matrix.User{ID: "@dev:f4814n.de"}, Room: room},
	}

	backend.UpdateRoomState(first)

	if ev := backend.RoomState(room, "de.f4814n.testing", "test"); ev == nil {
		t.Fatal("could not find state event")
	} else if ev.Content["first"] != "content" {
		t.Fatal("state event contained wrong content")
	}

	other := matrix.StateEvent{
		Content: map[string]interface{}{
			"other": "content",
		},
		Type:     "de.f4814n.testing",
		ID:       "bbbbbbbbbbbbbbb",
		StateKey: "test1",
		Room:     room,
		Sender:   matrix.Member{User: matrix.User{ID: "@dev:f4814n.de"}, Room: room},
	}
	backend.UpdateRoomState(other)

	res := backend.RoomStateList(room, "de.f4814n.testing")

	if (res[0].Content["other"] != "content" && res[1].Content["first"] != "content") &&
		(res[1].Content["other"] != "content" && res[0].Content["first"] != "content") {
		t.Fatal("got wrong list of state events")
	}
}

func testLatestEvents(t *testing.T, backend matrix.Backend) {
	t.Parallel()

	room := matrix.Room{ID: "!asdfasdfadf:f4814n.de"}

	events := []matrix.Event{
		matrix.RoomEvent{
			Content: map[string]interface{}{
				"a": []string{"1", "2", "3"},
			},
			Type: "de.f4814n.testing",
		},
		matrix.StateEvent{
			Content: map[string]interface{}{
				"b": "b",
			},
			Type:     "de.f4814n.testing",
			StateKey: "abc",
		},
	}

	batch := "asdfasdfasfdsad"

	backend.AddLatestEvents(room, events, batch)

	ev, newBatch := backend.LatestEvents(room)

	if newBatch != batch {
		t.Fatal("got wrong batch ID")
	}

	if !reflect.DeepEqual(ev[0], events[0]) && reflect.DeepEqual(ev[1], events[1]) {
		t.Fatal("got wrong events")
	}
}

A matrix/client.go => matrix/client.go +446 -0
@@ 0,0 1,446 @@
package matrix

import (
	"context"
	"errors"
	"net/http"
	"sync"

	"git.sr.ht/~f4814n/frost/matrix/api"
)

var ErrUnsupportedVersion = errors.New("the homeserver does not support API version r0.5.0. The Client might or might not work correctly")

// Client implements communication using the matrix protocol
type Client struct {
	// A event is sent to this channel when the first sync request has completed.
	// If the Sync routine is stopped and then restarted a event is again sent.
	InitialSyncDone chan struct{}

	notifierChannels []notifierChannel

	backend Backend
	http    *http.Client
	api     *api.Client
	auth    api.Auth

	mut sync.RWMutex
}

// ClientOpts are optional options passed to Client. NewClient is able to handle
// ClientOpts values which have nil fields or even are nil.
type ClientOpts struct {
	// HTTP Client to use.
	HTTPClient *http.Client

	// Backend
	Backend Backend
}

// NewClient creates a new client. This only initializes everything needed but
// does not make any attempt to use the network.
func NewClient(opts ClientOpts) *Client {
	return &Client{
		backend:          opts.Backend,
		notifierChannels: make([]notifierChannel, 0),
		InitialSyncDone:  make(chan struct{}, 1),
		http:             opts.HTTPClient,
	}
}

// AccessToken returns the access token used by the client. "" if there is no
// token
func (cli *Client) AccessToken() string {
	return cli.backend.AccessToken()
}

// DeviceID returns the device ID of the Client.
func (cli *Client) DeviceID() string {
	return cli.backend.DeviceID()
}

// Login using a matrix user id and a password (m.login.password)
func (cli *Client) Login(mxid string, password string) error {
	cli.mut.Lock() // Before Login is done the client is useless anyway.
	defer cli.mut.Unlock()

	localpart, serverName, err := SplitMxID(mxid)
	if err != nil {
		return LogicError{Err: err}
	}

	homeserver, err := ResolveHomeserverURL(serverName)
	if err != nil {
		return err
	}

	cli.api = api.NewClient(cli.http, homeserver.Host)

	if err := cli.checkVersions(); err != nil {
		return err
	}

	body := api.LoginBody{
		Type:       "m.login.password",
		Identifier: api.NewUserIdentifer(localpart),
		Password:   password,
	}

	resp, err := cli.api.Login(body)
	if err != nil {
		return makeError(err)
	}

	cli.auth = func(r *http.Request) {
		r.Header.Add("Authorization", "Bearer "+resp.AccessToken)
	}
	cli.backend.Initialize(mxid, resp.DeviceID, resp.AccessToken)

	return nil
}

// LoadToken calls the Open method of the backend with the provided parameters,
// checks if the homeserver exists, configures the client to use the homeserver
// and finally calls the whoami API endpoint to check, whether the access token
// matches the mxID.
func (cli *Client) LoadToken(mxid, deviceID string) error {
	if err := cli.backend.Open(mxid, deviceID); err != nil {
		return LogicError{Err: err}
	}

	_, serverName, err := SplitMxID(mxid)
	if err != nil {
		return LogicError{Err: err}
	}

	homeserver, err := ResolveHomeserverURL(serverName)
	if err != nil {
		return err
	}

	cli.api = api.NewClient(cli.http, homeserver.Host)
	if err := cli.checkVersions(); err != nil {
		return err
	}

	cli.auth = func(r *http.Request) {
		r.Header.Add("Authorization", "Bearer "+cli.backend.AccessToken())
	}

	resp, err := cli.api.Whoami(cli.auth)
	if err != nil {
		return makeError(err)
	}

	if resp.UserID != mxid {
		return LogicError{Err: errors.New("backend returned a valid access token not associated with this mxID. What is going on?")}
	}

	return nil
}

// chechVersions checks whether the server supports the API version we use
// (currently r0.5.0)
func (cli *Client) checkVersions() error {
	resp, err := cli.api.Versions()
	if err != nil {
		return makeError(err)
	}

	for _, version := range resp.Versions {
		if version == "r0.5.0" {
			return nil
		}
	}

	return LogicError{Err: ErrUnsupportedVersion}

}

// Logout invalidates the used access token
func (cli *Client) Logout() error {
	return makeError(cli.api.Logout(cli.auth))
}

// LogoutAll invalidates all access tokens of the user
func (cli *Client) LogoutAll() error {
	return makeError(cli.api.LogoutAll(cli.auth))
}

// User returns the user the client is authenticated as. Nil if the client is not
// authenticated.
func (cli *Client) User() *User {
	if cli.backend.MxID() == "" {
		return nil
	}

	return &User{
		ID:  cli.backend.MxID(),
		cli: cli,
	}
}

// SyncOpts are optional options to supply to the Sync function
type SyncOpts struct {
	// Execute this function if there if an error. If this returns nil, Sync
	// will sleep for SyncOpts.Timeout and then continue otherwise Sync()
	// will exit, and return the produced error.
	OnError func(error) error

	// Timeout in ms used for long-polling /sync. 3000 is used if this is negative.
	Timeout int64
}

func (cli *Client) sync(ctx context.Context, timeout int64, nextBatch string) (string, error) {
	resp, err := cli.api.Sync(cli.auth, "", nextBatch, nextBatch == "", "online", timeout)
	if err != nil {
		return "", makeError(err)
	}

	cli.syncResp(resp)

	return resp.NextBatch, nil
}

func (cli *Client) syncResp(resp api.SyncResp) {
	for _, ev := range resp.AccountData.Events {
		event := AccountDataEvent{
			Type:    ev.Type,
			Content: ev.Content,
		}

		cli.backend.UpdateAccountData(event)
		cli.handleEvent(event)
	}

	for id, body := range resp.Rooms.Join {
		room := Room{ID: id, cli: cli}
		for _, ev := range body.AccountData.Events {
			event := AccountDataEvent{
				Type:    ev.Type,
				Content: ev.Content,
				room:    &room,
			}

			cli.backend.UpdateAccountData(event)
			cli.handleEvent(event)
		}

		for _, ev := range body.Ephemeral.Events {
			event := make(EphemeralEvent)
			event["content"] = ev.Content
			event["type"] = ev.Type

			cli.handleEvent(event)
		}

		for _, ev := range body.State.Events {
			sender := loadUserUnsafe(cli, ev.Sender)
			event := StateEvent{
				Content:        ev.Content,
				Type:           ev.Type,
				ID:             ev.EventID,
				OriginServerTS: timestampToTime(ev.OriginServerTS),
				Unsigned: RoomEventUnsigned{ // TODO RedactedBecause
					Age:           ev.Unsigned.Age,
					TransactionID: ev.Unsigned.TransactionID,
				},
				StateKey:    *ev.StateKey,
				PrevContent: ev.PrevContent,
				Sender:      Member{User: sender, Room: room},
				Room:        room,
			}

			cli.backend.UpdateRoomState(event)
			cli.handleEvent(event)
		}

		// TODO body.Summary

		batch := make([]Event, len(body.Timeline.Events))
		for i, ev := range body.Timeline.Events { // TODO respect Limited
			sender := loadUserUnsafe(cli, ev.Sender)
			var event Event

			if ev.StateKey != nil {
				event = StateEvent{
					Content:        ev.Content,
					Type:           ev.Type,
					ID:             ev.EventID,
					OriginServerTS: timestampToTime(ev.OriginServerTS),
					Unsigned: RoomEventUnsigned{ // TODO RedactedBecause
						Age:           ev.Unsigned.Age,
						TransactionID: ev.Unsigned.TransactionID,
					},
					PrevContent: ev.PrevContent,
					StateKey:    *ev.StateKey,
					Sender:      Member{User: sender, Room: room},
					Room:        room,
				}
			} else {
				event = RoomEvent{
					Content:        ev.Content,
					Type:           ev.Type,
					ID:             ev.EventID,
					OriginServerTS: timestampToTime(ev.OriginServerTS),
					Unsigned: RoomEventUnsigned{ // TODO RedactedBecause
						Age:           ev.Unsigned.Age,
						TransactionID: ev.Unsigned.TransactionID,
					},
					Sender: Member{User: sender, Room: room},
					Room:   room,
				}
			}
			batch[i] = event
		}

		// We do not want to call backend.AddLatestEvents with empty arguments
		if len(body.Timeline.Events) != 0 {
			cli.backend.AddLatestEvents(room, batch, body.Timeline.PrevBatch)
			cli.handleEvents(batch)
		}

		// TODO body.UnreadNotifications
	}

	for id, body := range resp.Rooms.Invite {
		room := loadRoomUnsafe(cli, id)

		for _, ev := range body.InviteState.Events {
			sender := loadUserUnsafe(cli, ev.Sender)
			event := StateEvent{
				Content:  ev.Content,
				Type:     ev.Type,
				StateKey: ev.StateKey,
				Sender:   Member{User: sender, Room: room},
				Room:     room,
			}

			cli.backend.UpdateRoomState(event)
			cli.handleEvent(event)
		}
	}

	for id, body := range resp.Rooms.Leave {
		room := loadRoomUnsafe(cli, id)
		for _, ev := range body.AccountData.Events {
			event := AccountDataEvent{
				Type:    ev.Type,
				Content: ev.Content,
				room:    &room,
			}

			cli.backend.UpdateAccountData(event)
			cli.handleEvent(event)
		}

		for _, ev := range body.State.Events {
			sender := loadUserUnsafe(cli, ev.Sender)
			event := StateEvent{
				Content:        ev.Content,
				Type:           ev.Type,
				ID:             ev.EventID,
				StateKey:       *ev.StateKey,
				OriginServerTS: timestampToTime(ev.OriginServerTS),
				Unsigned: RoomEventUnsigned{ // TODO RedactedBecause
					Age:           ev.Unsigned.Age,
					TransactionID: ev.Unsigned.TransactionID,
				},
				Sender: Member{User: sender, Room: room},
				Room:   room,
			}

			cli.backend.UpdateRoomState(event)
			cli.handleEvent(event)
		}
	}
}

// handleEvent reacts to a incoming event.
func (cli *Client) handleEvent(e Event) {
	cli.mut.RLock()
	defer cli.mut.RUnlock()

	for _, c := range cli.notifierChannels {
		if c.send(e) {
			c.ch <- e
		}
	}
}

func (cli *Client) handleEvents(events []Event) {
	for _, e := range events {
		cli.handleEvent(e)
	}
}

// Sync starts receiving change events. This will start long-polling /sync and supply
// all Changers with new events. This can be stopped and then restarted without
// having to discard Changers. Stop this by terminating ctx.
// The returned error is either a NetworkError, a LogicError or Context.Canceled /
// Context.DeadlineExceeded.
func (cli *Client) Sync(ctx context.Context, opts *SyncOpts) error {
	if opts == nil {
		opts = &SyncOpts{}
	}

	if opts.Timeout <= 0 {
		opts.Timeout = 3000
	}

	var (
		nextBatch string
		err       error
	)

	initialSync := true

	for {
		nextBatch, err = cli.sync(ctx, opts.Timeout, nextBatch)

		select { // First check if the context was closed. In that case we just ignore the error
		case <-ctx.Done():
			return ctx.Err()
		default:
		}

		if err != nil {
			if err := opts.OnError(err); err != nil {
				return err
			}
			continue
		}

		if initialSync {
			cli.InitialSyncDone <- struct{}{}
			initialSync = false
		}
	}
}

// Notify implements the Notify interface. This will contain all events received.
func (cli *Client) Notify(c chan<- Event) {
	cli.mut.Lock()
	defer cli.mut.Unlock()

	send := func(Event) bool {
		return true
	}

	cli.notifierChannels = append(cli.notifierChannels, notifierChannel{ch: c, send: send})
}

// Stop implements the Notifier interface.
func (cli *Client) Stop(c chan<- Event) {
	cli.mut.Lock()
	defer cli.mut.Unlock()

	n := make([]notifierChannel, 0)
	for _, x := range cli.notifierChannels {
		if x.ch != c {
			n = append(n, x)
		}
	}

	cli.notifierChannels = n
}

A matrix/doc.go => matrix/doc.go +10 -0
@@ 0,0 1,10 @@
// Package matrix implements a matrix client. This includes a lot of abstractionns on top of
// the http api. This package tries to use as few as possible HTTP requests. We
// try to accomplish this by saving as much information as possible from the /sync
// API endpoint. This means that whenever we initialize a new backend, we do a
// request with full_state=true which might take some seconds to complete. To avoid
// this you should use a persistent backend.
// This package is fully thread-safe.
// All errors returned by methods in this package are (unless otherwise noted)
// either a NetworkError or a LogicError wrapping the original error
package matrix

A matrix/errors.go => matrix/errors.go +42 -0
@@ 0,0 1,42 @@
package matrix

import "git.sr.ht/~f4814n/frost/matrix/api"

// A NetworkError is returned, if a correct HTTP transmission was not possible
// for whatever reason
type NetworkError struct {
	Err error
}

func (e NetworkError) Error() string {
	return "network error: " + e.Err.Error()
}

func (e NetworkError) Unwrap() error {
	return e.Err
}

// A LogicError is returned, if the HTTP connection was successful but the
// operation failed anyway. (For example due to insufficient authorization)
type LogicError struct {
	Err error
}

func (e LogicError) Error() string {
	return "logic error: " + e.Err.Error()
}

func (e LogicError) Unwrap() error {
	return e.Err
}

func makeError(err error) error {
	if err == nil {
		return nil
	}

	if _, ok := err.(api.MatrixError); ok {
		return LogicError{Err: err}
	}
	return NetworkError{Err: err} // TODO NetworkError vs LogicError
}

A matrix/event.go => matrix/event.go +99 -0
@@ 0,0 1,99 @@
package matrix

import "time"

// Event is the interface implemented by the types to represent all kinds of
// matrix events.
type Event interface {
	GetType() string
	GetContent() map[string]interface{}
}

// EphemeralEvent is a ephemeral matrix event.
// TODO Room information
type EphemeralEvent map[string]interface{}

// GetType implements the Event interface.
func (e EphemeralEvent) GetType() string {
	return e["type"].(string)
}

// GetContent implements the Event interface.
func (e EphemeralEvent) GetContent() map[string]interface{} {
	return e["content"].(map[string]interface{})
}

// RoomEvent is a matrix room event that is not a state event at the same time.
type RoomEvent struct {
	Content        map[string]interface{}
	Type           string
	ID             string
	OriginServerTS time.Time
	Unsigned       RoomEventUnsigned
	Sender         Member
	Room           Room
}

// RoomEventUnsigned is used within RoomEvent.
type RoomEventUnsigned struct {
	Age             int64
	RedactedBecause Event
	TransactionID   string
}

// GetType implements the Event interface.
func (e RoomEvent) GetType() string {
	return e.Type
}

// GetContent implements the Event interface.
func (e RoomEvent) GetContent() map[string]interface{} {
	return e.Content
}

// StateEvent is a matrix state event.
type StateEvent struct {
	Content        map[string]interface{}
	Type           string
	ID             string
	OriginServerTS time.Time
	Unsigned       RoomEventUnsigned
	StateKey       string
	PrevContent    map[string]interface{}
	Sender         Member
	Room           Room
}

// GetType implements the Event interface
func (e StateEvent) GetType() string {
	return e.Type
}

// GetContent implements the Event interface
func (e StateEvent) GetContent() map[string]interface{} {
	return e.Content
}

// AccountDataEvent is a matrix account data event. If this event is attached to
// a Room, this Room can be obtained using the Room method.
type AccountDataEvent struct {
	Type    string
	Content map[string]interface{}

	room *Room
}

// GetType implements the Event interface.
func (e AccountDataEvent) GetType() string {
	return e.Type
}

// GetContent implements the Event interface
func (e AccountDataEvent) GetContent() map[string]interface{} {
	return e.Content
}

// Room returns the room on which the event was stored.
func (e AccountDataEvent) Room() *Room {
	return e.room
}

A matrix/examples/echo/main.go => matrix/examples/echo/main.go +60 -0
@@ 0,0 1,60 @@
package main

import (
	"context"
	"fmt"
	"net/http"
	_ "net/http/pprof"

	"git.sr.ht/~f4814n/frost/matrix"
	membackend "git.sr.ht/~f4814n/frost/matrix/backend/memory"
	"git.sr.ht/~f4814n/frost/matrix/examples"
)

func main() {
	cli := matrix.NewClient(matrix.ClientOpts{
		Backend:    membackend.New(),
		HTTPClient: http.DefaultClient,
	})
	mxid, pw := examples.QueryCredentials()

	err := cli.Login(mxid, pw)
	if err != nil {
		panic(err)
	}
	fmt.Println("Authenticated")

	go cli.Sync(context.Background(), &matrix.SyncOpts{Timeout: 300,
		OnError: func(err error) error {
			fmt.Printf("%#v\n", err)
			return nil
		}})

	// Wait until the initial sync is done. We do not want to react on all the
	// events returned by initial sync
	<-cli.InitialSyncDone

	fmt.Println("Waiting for events")

	c := make(chan matrix.Event)
	cli.Notify(c)

	for {
		select {
		case e := <-c:
			go echo(cli, e)
		}
	}
}

// Send a echo, if the event type is "m.room.message" and we are not the sender
func echo(cli *matrix.Client, ev matrix.Event) {
	if event, ok := ev.(matrix.RoomEvent); ok {
		if event.Type == "m.room.message" && event.Sender.ID != (*cli.User()).ID {
			_, err := event.Room.SendRoomEvent("m.room.message", event.Content)
			if err != nil {
				fmt.Printf("%#v\n", err)
			}
		}
	}
}

A matrix/examples/history/main.go => matrix/examples/history/main.go +54 -0
@@ 0,0 1,54 @@
package main

import (
	"bufio"
	"context"
	"fmt"
	"net/http"
	"os"

	"git.sr.ht/~f4814n/frost/matrix"
	membackend "git.sr.ht/~f4814n/frost/matrix/backend/memory"
	"git.sr.ht/~f4814n/frost/matrix/examples"
)

func main() {
	cli := matrix.NewClient(matrix.ClientOpts{
		Backend:    membackend.New(),
		HTTPClient: http.DefaultClient,
	})
	mxid, pw := examples.QueryCredentials()

	reader := bufio.NewReader(os.Stdin)
	fmt.Print("Enter room ID/alias: ")
	roomID, _ := reader.ReadString('\n')
	roomID = roomID[:len(roomID)-1]

	err := cli.Login(mxid, pw)
	if err != nil {
		panic(err)
	}
	fmt.Println("Authenticated")

	go cli.Sync(context.Background(), &matrix.SyncOpts{Timeout: 3000, OnError: examples.ErrorHandler})

	// The initial sync must be completed so we know the prev_token of the sync request
	<-cli.InitialSyncDone

	room, err := matrix.LoadRoom(cli, roomID)
	if err != nil {
		panic(err)
	}

	history := room.History()

	for history.Next() {
		if history.Err != nil {
			panic(err)
		}

		for _, event := range history.Events {
			fmt.Printf("%#v\n", event)
		}
	}
}

A matrix/examples/login/main.go => matrix/examples/login/main.go +31 -0
@@ 0,0 1,31 @@
package main

import (
	"fmt"
	"net/http"

	"git.sr.ht/~f4814n/frost/matrix"
	membackend "git.sr.ht/~f4814n/frost/matrix/backend/memory"
	"git.sr.ht/~f4814n/frost/matrix/examples"
)

func main() {
	cli := matrix.NewClient(matrix.ClientOpts{
		Backend:    membackend.New(),
		HTTPClient: http.DefaultClient,
	})
	mxid, pw := examples.QueryCredentials()

	err := cli.Login(mxid, pw)
	if err != nil {
		panic(err)
	}

	fmt.Printf("Got access token %s and device ID %s\n", cli.AccessToken(), cli.DeviceID())

	fmt.Println("Logging out")
	err = cli.Logout()
	if err != nil {
		panic(err)
	}
}

A matrix/examples/read_room/main.go => matrix/examples/read_room/main.go +79 -0
@@ 0,0 1,79 @@
package main

import (
	"bufio"
	"context"
	"fmt"
	"net/http"
	"os"

	"git.sr.ht/~f4814n/frost/matrix"
	membackend "git.sr.ht/~f4814n/frost/matrix/backend/memory"
	"git.sr.ht/~f4814n/frost/matrix/examples"
)

func main() {
	cli := matrix.NewClient(matrix.ClientOpts{
		Backend:    membackend.New(),
		HTTPClient: http.DefaultClient,
	})

	mxid, pw := examples.QueryCredentials()

	reader := bufio.NewReader(os.Stdin)
	fmt.Print("Enter room ID/alias: ")
	roomID, _ := reader.ReadString('\n')
	roomID = roomID[:len(roomID)-1]

	err := cli.Login(mxid, pw)
	if err != nil {
		panic(err)
	}
	fmt.Println("Authenticated")

	go cli.Sync(context.Background(), &matrix.SyncOpts{Timeout: 300, OnError: errorHandler})

	<-cli.InitialSyncDone

	room, err := matrix.LoadRoom(cli, roomID)
	if err != nil {
		panic(err)
	}

	state := matrix.Member{User: *cli.User(), Room: room}.State()
	if state != matrix.Join {
		fmt.Printf("Your state is %s. This might not work as expected\n", state)
	}

	fmt.Println("Printing room log...")

	c := make(chan matrix.Event, 1)
	room.Notify(c)

	for {
		select {
		case e := <-c:
			format(e)
		}
	}
}

func errorHandler(err error) error {
	fmt.Printf("%#v\n", err)
	return nil
}

func format(e matrix.Event) {
	switch e.GetType() {
	case "m.room.message":
		switch e.(matrix.RoomEvent).Content["msgtype"] {
		case "m.text":
			name := e.(matrix.RoomEvent).Sender.Displayname()
			fmt.Printf("%s: %s\n", name, e.(matrix.RoomEvent).Content["body"])
		default:
			fmt.Printf("Unhandled: %+v\n", e)
		}
	default:
		fmt.Printf("Unhandled: %+v\n", e)
	}
}

A matrix/examples/simple_notify/main.go => matrix/examples/simple_notify/main.go +38 -0
@@ 0,0 1,38 @@
package main

import (
	"context"
	"fmt"

	"net/http"

	"git.sr.ht/~f4814n/frost/matrix"
	membackend "git.sr.ht/~f4814n/frost/matrix/backend/memory"
	"git.sr.ht/~f4814n/frost/matrix/examples"
)

func main() {
	cli := matrix.NewClient(matrix.ClientOpts{
		Backend:    membackend.New(),
		HTTPClient: http.DefaultClient,
	})
	mxid, pw := examples.QueryCredentials()

	err := cli.Login(mxid, pw)
	if err != nil {
		panic(err)
	}

	ch := make(chan matrix.Event)
	cli.Notify(ch)

	go func() {
		for e := range ch {
			fmt.Printf("%#v\n", e)
		}
	}()

	if err := cli.Sync(context.Background(), nil); err != nil {
		panic(err)
	}
}

A matrix/examples/util.go => matrix/examples/util.go +25 -0
@@ 0,0 1,25 @@
package examples

import (
	"bufio"
	"fmt"
	"os"
)

func QueryCredentials() (string, string) {
	reader := bufio.NewReader(os.Stdin)
	fmt.Print("Enter mxid: ")
	mxid, _ := reader.ReadString('\n')
	mxid = mxid[:len(mxid)-1]

	fmt.Print("Enter password: ")
	pw, _ := reader.ReadString('\n')
	pw = pw[:len(pw)-1]

	return mxid, pw
}

func ErrorHandler(err error) error {
	fmt.Printf("%#v\n", err)
	return nil
}

A matrix/history.go => matrix/history.go +84 -0
@@ 0,0 1,84 @@
package matrix

import (
	"git.sr.ht/~f4814n/frost/matrix/api"
)

// History makes the room history accessible.
type History struct {
	// Contains the current events. Paginated using History.Next()
	Events []Event
	Err    error

	prevToken *string
	cli       *Client
	room      Room
}

// Next uses the room messages API to traverse the history in a backwards way.
// If there is a communication error, next sets History.Err to the error and
// returns true. Even though the messages API does not return the most recent event
// (if it was sent by the last sync response), History includes those events too.
func (h *History) Next() bool {
	if h.prevToken == nil {
		var (
			prevToken string
			events    []Event
		)
		events, prevToken = h.cli.backend.LatestEvents(h.room)
		h.prevToken = &prevToken
		h.Events = events
		return true
	}

	resp, err := h.cli.api.GetRoomMessages(h.cli.auth, h.room.ID, *h.prevToken, "", "b", 10, "")
	if err != nil {
		h.Err = makeError(err)
		return true
	}

	h.processResp(resp)

	return len(h.Events) != 0
}

func (h *History) processResp(resp api.GetRoomMessagesResp) {
	h.prevToken = &resp.End

	var events []Event
	for _, e := range resp.Chunk {
		var event Event
		if e.StateKey != nil {
			event = StateEvent{
				Content:        e.Content,
				Type:           e.Type,
				ID:             e.EventID,
				OriginServerTS: timestampToTime(e.OriginServerTS),
				Unsigned: RoomEventUnsigned{ // TODO RedactedBecause
					Age:           e.Unsigned.Age,
					TransactionID: e.Unsigned.TransactionID,
				},
				StateKey:    *e.StateKey,
				PrevContent: e.PrevContent,
				Sender:      Member{User: loadUserUnsafe(h.cli, e.Sender), Room: h.room},
				Room:        h.room,
			}
		} else {
			event = RoomEvent{
				Content:        e.Content,
				Type:           e.Type,
				ID:             e.EventID,
				OriginServerTS: timestampToTime(e.OriginServerTS),
				Unsigned: RoomEventUnsigned{ // TODO RedactedBecause
					Age:           e.Unsigned.Age,
					TransactionID: e.Unsigned.TransactionID,
				},
				Sender: Member{User: loadUserUnsafe(h.cli, e.Sender), Room: h.room},
				Room:   h.room,
			}
		}
		events = append(events, event)
	}

	h.Events = events
}

A matrix/homeserver.go => matrix/homeserver.go +41 -0
@@ 0,0 1,41 @@
package matrix

import (
	"encoding/json"
	"net/http"
	"net/url"
)

// ResolveHomeserverURL gets the actual URL of a homeserver based on the server
// name from a mxid. It returns a NetworkError if the http transport was not successful and a
// LogicError if the server provided a broken response (e.g malformatted JSON)
// TODO Identity server & extra information
// XXX Really use LogicError here?
func ResolveHomeserverURL(serverName string) (*url.URL, error) {
	resp, err := http.Get("https://" + serverName + "/.well-known/matrix/client")
	if err != nil {
		return nil, NetworkError{Err: err}
	}
	defer resp.Body.Close()

	if resp.StatusCode == 404 {
		return &url.URL{Scheme: "https", Host: serverName}, nil
	}

	var wellKnown struct {
		Homeserver struct {
			BaseURL string `json:"base_url"`
		} `json:"m.homeserver"`
	}

	err = json.NewDecoder(resp.Body).Decode(&wellKnown)
	if err != nil {
		return nil, LogicError{Err: err}
	}

	url, err := url.Parse(wellKnown.Homeserver.BaseURL)
	if err != nil {
		return nil, LogicError{Err: err}
	}
	return url, nil
}

A matrix/membershipstate_string.go => matrix/membershipstate_string.go +28 -0
@@ 0,0 1,28 @@
// Code generated by "stringer -type=MembershipState"; DO NOT EDIT.

package matrix

import "strconv"

func _() {
	// An "invalid array index" compiler error signifies that the constant values have changed.
	// Re-run the stringer command to generate them again.
	var x [1]struct{}
	_ = x[Invite-0]
	_ = x[Join-1]
	_ = x[Leave-2]
	_ = x[Ban-3]
	_ = x[Knock-4]
	_ = x[Unspecified-5]
}

const _MembershipState_name = "InviteJoinLeaveBanKnockUnspecified"

var _MembershipState_index = [...]uint8{0, 6, 10, 15, 18, 23, 34}

func (i MembershipState) String() string {
	if i >= MembershipState(len(_MembershipState_index)-1) {
		return "MembershipState(" + strconv.FormatInt(int64(i), 10) + ")"
	}
	return _MembershipState_name[_MembershipState_index[i]:_MembershipState_index[i+1]]
}

A matrix/mxid.go => matrix/mxid.go +36 -0
@@ 0,0 1,36 @@
package matrix

import (
	"errors"
	"strings"
)

const allowedLocalpartRunes string = "abcdefghijklmnopqrstuvwxyz0123456789._=-/"

// SplitMxID splits a mxid and return localpart, server name and validity respectively.
func SplitMxID(id string) (localpart string, serverName string, err error) {
	if id[0] != '@' {
		err = errors.New("id does not begin with @")
		return
	}

	id = id[1:]

	sp := strings.Split(string(id), ":")
	if len(sp) != 2 {
		err = errors.New("id does not contain a colon(:)")
		return
	}

	serverName = sp[1]
	localpart = sp[0]
	for _, c := range localpart {
		if !strings.Contains(allowedLocalpartRunes, string(c)) {
			err = errors.New("id contains unallowed character")
			return
		}
	}

	err = nil
	return
}

A matrix/notifier.go => matrix/notifier.go +21 -0
@@ 0,0 1,21 @@
package matrix

// A Notifier (Client, User, Member, Room) can receive updated events from a running
// Client.
type Notifier interface {
	// A copy of each event relecant to the Updater is sent here. It is up to the
	// use to ensure that the channel is empty. The event will be discarded, if
	// the channel is not empty. Calling this method with the same argument
	// twice will panic.
	Notify(chan<- Event)

	// Stop sending to the specified channel. This panics if the channel was
	// not registered before using Updater.Update() or if called multiple
	// times.
	Stop(chan<- Event)
}

type notifierChannel struct {
	send func(e Event) bool
	ch   chan<- Event
}

A matrix/room.go => matrix/room.go +170 -0
@@ 0,0 1,170 @@
package matrix

import (
	"errors"
	"time"
)

// Room represents a matrix room. A room value is always tied to a client, that
// is used to do the actual communication. Multiple room values describing the
// same room may be used simultaneously. Room is thread-safe.
type Room struct {
	// The matrix ID of the room
	ID string

	cli *Client
}

// loadRoomUnsafe just creates a room object without checking for existence.
func loadRoomUnsafe(cli *Client, id string) Room {
	return Room{
		ID:  id,
		cli: cli,
	}
}

// LoadRoom takes a id or alias as argument and calls LoadRoomID or LoadRoomAlias
// depending on the first sign of the id.
func LoadRoom(cli *Client, id string) (Room, error) {
	if id[0] == '!' {
		return LoadRoomID(cli, id)
	} else if id[0] == '#' {
		return LoadRoomAlias(cli, id)
	}

	return Room{}, LogicError{Err: errors.New("invalid room alias")}
}

// LoadRoomID loads a existent room identified by the room ID. This call is very
// fast (no HTTP Request) for rooms the user is invited to, has joined or has
// already left (for all rooms that are somehow part of the /sync endpoint).
// For other rooms this tries to look up the ID.
func LoadRoomID(cli *Client, id string) (Room, error) {
	room := Room{
		ID:  id,
		cli: cli,
	}

	// Check if we already know the room exists
	if ev := cli.backend.RoomState(room, "m.room.create", ""); ev != nil {
		return room, nil
	}

	// We have never seen the room. Query the room directory
	_, err := cli.api.GetRoomVisibility(id)
	return room, makeError(err)
}

// LoadRoomAlias loads a existent room identified by the room alias.
func LoadRoomAlias(cli *Client, alias string) (Room, error) {
	resp, err := cli.api.GetRoomAliasID(alias)
	if err != nil {
		return Room{}, makeError(err)
	}

	room := Room{
		ID:  resp.RoomID,
		cli: cli,
	}
	return room, nil
}

// GetState gets a specific event from the room state. If no such state event is
// known the return value is nil.
func (r Room) GetState(eventType, stateKey string) *StateEvent {
	return r.cli.backend.RoomState(r, eventType, stateKey)
}

// SendRoomEvent sends a room event to the room and returns the event ID and
// possibly an error.
func (r Room) SendRoomEvent(eventType string, content map[string]interface{}) (string, error) {
	r.cli.mut.RLock()
	defer r.cli.mut.RUnlock()

	resp, err := r.cli.api.PutRoomEvent(r.cli.auth, r.ID, eventType, time.Now().String(), content)
	return resp.EventID, makeError(err)
}

// SendStateEvent sends a state event to the room and returns the event ID and
// possibly an error.
func (r Room) SendStateEvent(eventType string, stateKey string, content map[string]interface{}) (string, error) {
	r.cli.mut.RLock()
	defer r.cli.mut.RUnlock()

	resp, err := r.cli.api.PutStateEvent(r.cli.auth, r.ID, eventType, stateKey, content)
	return resp.EventID, makeError(err)
}

// Members returns all Members of this room. At the moment lazy loading of room members
// is not used. So this method can not return an error.
func (r Room) Members() []Member {
	r.cli.mut.RLock()
	defer r.cli.mut.RUnlock()

	m := r.cli.backend.RoomStateList(r, "m.room.member")
	members := make([]Member, len(m))
	for _, m := range m {
		member := Member{
			Room: r,
			User: loadUserUnsafe(r.cli, m.StateKey),
		}

		members = append(members, member)
	}

	return members
}

// History makes the history beginning from the most recently received prev_batch
// (/sync endpoint) accessible.
func (r Room) History() History {
	return History{
		room: r,
		cli:  r.cli,
	}
}

// Invite a User to a Room.
func (r Room) Invite(u User) error {
	return nil
}

// Displayname returns the rooms display name. The name is calculated according to
// https://matrix.org/docs/spec/client_server/r0.5.0#calculating-the-display-name-for-a-room
func (r Room) Displayname() string {
	if state := r.cli.backend.RoomState(r, "m.room.name", ""); state != nil {
		if name, ok := state.Content["name"]; ok && name != "" {
			return name.(string)
		}
	}

	if state := r.cli.backend.RoomState(r, "m.room.canonical_alias", ""); state != nil {
		if alias, ok := state.Content["alias"]; ok && alias != "" {
			return alias.(string)
		}
	}

	return r.ID
}

// AccountData returns an AccountDataEvent of the given type if it exists
func (r Room) AccountData(type_ string) *AccountDataEvent {
	return r.cli.backend.AccountData(&r, type_)
}

// Notify implements the Notifier interface
func (r Room) Notify(c chan<- Event) {
	r.cli.mut.Lock()
	defer r.cli.mut.Unlock()

	send := func(e Event) bool {
		return inRoom(e, r)
	}

	r.cli.notifierChannels = append(r.cli.notifierChannels, notifierChannel{send: send, ch: c})
}

// Stop implements the Notifier interface
func (r Room) Stop(c chan<- Event) {
	r.cli.Stop(c)
}

A matrix/user.go => matrix/user.go +124 -0
@@ 0,0 1,124 @@
package matrix

//go:generate stringer -type=MembershipState
type MembershipState uint8

const (
	Invite MembershipState = iota
	Join
	Leave
	Ban
	Knock
	Unspecified
)

// User is matrix User.
type User struct {
	// The matrix ID of the user
	ID string

	cli *Client
}

func loadUserUnsafe(cli *Client, id string) User {
	return User{
		ID:  id,
		cli: cli,
	}
}

// LoadUser tries to find a matrix user. It returns a LogicError if the user does
// not exist.
func LoadUser(cli *Client, id string) (User, error) {
	return User{}, nil
}

// Displayname gets the display name of the user by calling the HTTP API.
func (u User) Displayname() (string, error) {
	resp, err := u.cli.api.GetUserDisplayname(u.ID)
	return resp.Displayname, makeError(err)
}

// Notify implements the Notifier interface
func (u User) Notify(c chan<- Event) {
	u.cli.mut.Lock()
	defer u.cli.mut.Unlock()

	send := func(e Event) bool {
		switch e := e.(type) {
		case StateEvent:
			return e.Sender.ID == u.ID
		case RoomEvent:
			return e.Sender.ID == u.ID
		}
		return false
	}

	u.cli.notifierChannels = append(u.cli.notifierChannels, notifierChannel{send: send, ch: c})
}

// Stop implements the Notifier interface
func (u User) Stop(c chan<- Event) {
	u.cli.Stop(c)
}

// Member is a User with the additional information from a single room.
type Member struct {
	User
	Room Room
}

// Displayname gets the displayname of the user by searching m.room.member state
// events.
func (m Member) Displayname() string {
	state := m.Room.cli.backend.RoomState(m.Room, "m.room.member", m.User.ID)
	if s, ok := state.Content["displayname"]; ok {
		return s.(string)
	}
	return m.User.ID
}

func (m Member) State() MembershipState {
	state := m.Room.cli.backend.RoomState(m.Room, "m.room.member", m.User.ID)
	if state != nil {
		return Unspecified
	}

	switch state.Content["membership"] {
	case "invite":
		return Invite
	case "join":
		return Join
	case "knock":
		return Knock
	case "leave":
		return Leave
	case "ban":
		return Ban
	default:
		panic("unknown state key")
	}
}

// Notify implements the Notifier interface
func (m Member) Notify(c chan<- Event) {
	m.cli.mut.Lock()
	defer m.cli.mut.Unlock()

	send := func(e Event) bool {
		switch e := e.(type) {
		case StateEvent:
			return e.Sender.ID == m.ID && e.Room.ID == m.Room.ID
		case RoomEvent:
			return e.Sender.ID == m.ID && e.Room.ID == m.Room.ID
		}
		return false
	}

	m.cli.notifierChannels = append(m.cli.notifierChannels, notifierChannel{send: send, ch: c})
}

// Stop implements the Notifier interface
func (m Member) Stop(c chan<- Event) {
	m.cli.Stop(c)
}

A matrix/util.go => matrix/util.go +23 -0
@@ 0,0 1,23 @@
package matrix

import (
	"time"
)

func timestampToTime(ts int64) time.Time {
	return time.Unix(0, ts*int64(1000000))
}

func inRoom(e Event, r Room) bool {
	switch e := e.(type) {
	case RoomEvent:
		return e.Room.ID == r.ID
	case StateEvent:
		return e.Room.ID == r.ID
	case AccountDataEvent:
		if e.Room() != nil {
			return e.Room().ID == r.ID
		}
	}
	return false
}

M overview_page.go => overview_page.go +1 -1
@@ 8,7 8,7 @@ import (
	"gioui.org/layout"
	"gioui.org/op"
	"gioui.org/unit"
	"git.sr.ht/~f4814n/matrix"
	"git.sr.ht/~f4814n/frost/matrix"
	log "github.com/sirupsen/logrus"
)


M room_page.go => room_page.go +1 -1
@@ 12,7 12,7 @@ import (
	"gioui.org/unit"
	"gioui.org/widget"
	"gioui.org/widget/material"
	"git.sr.ht/~f4814n/matrix"
	"git.sr.ht/~f4814n/frost/matrix"
)

type SentRoomEvent struct {

A shell.nix => shell.nix +9 -0
@@ 0,0 1,9 @@
(import (
  let
    lock = builtins.fromJSON (builtins.readFile ./flake.lock);
  in fetchTarball {
    url = "https://github.com/edolstra/flake-compat/archive/${lock.nodes.flake-compat.locked.rev}.tar.gz";
    sha256 = lock.nodes.flake-compat.locked.narHash; }
) {
  src =  ./.;
}).shellNix

M util.go => util.go +1 -1
@@ 11,7 11,7 @@ import (
	"gioui.org/op/paint"
	"gioui.org/unit"
	"gioui.org/widget"
	"git.sr.ht/~f4814n/matrix"
	"git.sr.ht/~f4814n/frost/matrix"
)

func rgb(c uint32) color.RGBA {